WIP: revocation

This commit is contained in:
Jovan Krunić
2026-10-13 10:05:01 +02:00
parent f88e792188
commit 7016dfb86e
5 changed files with 218 additions and 43 deletions
+1
View File
@@ -28,6 +28,7 @@ const config = {
role: '$.eduPersonPrimaryAffiliation',
studentId: '$.employeeNumber',
},
revoke: 'https://idp.ub.uni-frankfurt.de/idp/profile/oauth2/revocation',
token: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/token',
userinfo: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/userinfo',
},
View File
@@ -1,6 +1,7 @@
import {
AppAuthError,
AuthorizationServiceConfiguration,
RevokeTokenRequest,
StorageBackend,
TokenRequestHandler,
TokenResponse,
@@ -61,6 +62,7 @@ describe('AuthProvider', () => {
let storageSetItem: jasmine.Spy;
let storageRemoveItem: jasmine.Spy;
let tokenRequest: jasmine.Spy;
let revokeRequest: jasmine.Spy;
/** Seeds storage with the given token and loads it through the public API. */
async function givenLoadedToken(token: TokenResponse | null): Promise<void> {
@@ -109,6 +111,7 @@ describe('AuthProvider', () => {
tokenRequest = spyOn(internals.tokenHandler, 'performTokenRequest').and.rejectWith(
new Error('Unexpected token request'),
);
revokeRequest = spyOn(internals.tokenHandler, 'performRevokeTokenRequest').and.resolveTo(true);
});
afterEach(() => {
@@ -437,6 +440,108 @@ describe('AuthProvider', () => {
});
});
describe('signOut', () => {
/** The [token, token_type_hint, client_id] of each revoke request, in order. */
const revokedTokens = () =>
revokeRequest.calls.allArgs().map(([, request]) => {
const json = (request as RevokeTokenRequest).toJson();
return [json.token, json.token_type_hint, json.client_id];
});
it('revokes the refresh token, then the access token', async () => {
await givenLoadedToken(makeToken({accessToken: 'access', refreshToken: 'refresh'}));
await provider.signOut();
expect(revokedTokens()).toEqual([
['refresh', 'refresh_token', 'test-client'],
['access', 'access_token', 'test-client'],
]);
expect(await isLoggedIn()).toBeFalse();
expect(storageRemoveItem).toHaveBeenCalledWith(TOKEN_RESPONSE_KEY);
});
it('revokes only the access token when there is no refresh token (browser)', async () => {
await givenLoadedToken(makeToken({accessToken: 'access', refreshToken: null}));
await provider.signOut();
expect(revokedTokens()).toEqual([['access', 'access_token', 'test-client']]);
});
it('logs out locally before the IdP answers', async () => {
await givenLoadedToken(makeToken());
let loggedInWhileRevoking: boolean | undefined;
revokeRequest.and.callFake(async () => {
loggedInWhileRevoking = await isLoggedIn();
return true;
});
await provider.signOut();
expect(loggedInWhileRevoking).toBeFalse();
});
it('still logs out and tries the access token when revoking the refresh token fails', async () => {
await givenLoadedToken(makeToken());
const events = recordEvents();
revokeRequest.and.returnValues(Promise.reject(new AppAuthError('Network Error')), Promise.resolve(true));
await provider.signOut();
expect(revokeRequest).toHaveBeenCalledTimes(2);
expect(await isLoggedIn()).toBeFalse();
expect(events).toEqual([AuthActions.SignOutSuccess, AuthActions.RevokeTokensFailed]);
});
it('does not wait for an IdP that does not answer', async () => {
await givenLoadedToken(makeToken());
revokeRequest.and.returnValue(new Promise(() => {}));
let done = false;
const signOut = provider.signOut().then(() => (done = true));
await flushPromises();
expect(done).toBeFalse();
expect(await isLoggedIn()).toBeFalse();
jasmine.clock().tick(5000);
await signOut;
expect(done).toBeTrue();
});
it('revokes nothing when no revocation endpoint is configured', async () => {
(provider as any).localConfiguration = new AuthorizationServiceConfiguration({
authorization_endpoint: 'https://idp.example.org/authorize',
token_endpoint: 'https://idp.example.org/token',
revocation_endpoint: '',
});
await givenLoadedToken(makeToken());
await provider.signOut();
expect(revokeRequest).not.toHaveBeenCalled();
expect(await isLoggedIn()).toBeFalse();
});
it('revokes nothing when asked not to', async () => {
await givenLoadedToken(makeToken());
await provider.signOut(undefined, false);
expect(revokeRequest).not.toHaveBeenCalled();
expect(await isLoggedIn()).toBeFalse();
});
it('revokes nothing when nobody is logged in', async () => {
await givenLoadedToken(null);
await provider.signOut();
expect(revokeRequest).not.toHaveBeenCalled();
});
});
describe('isInvalidGrant', () => {
it('detects invalid_grant as error message', () => {
expect(isInvalidGrant(new AppAuthError(INVALID_GRANT))).toBeTrue();
@@ -69,6 +69,11 @@ const MAX_EARLY_REFRESH_SECONDS = 60;
*/
const SESSION_CHECK_RETRY_MS = 60 * 1000;
/**
* Logout never waits longer than this for the IdP to revoke the tokens.
*/
const REVOKE_TIMEOUT_MS = 5000;
/**
* Largest delay setTimeout accepts (about 24.8 days).
*/
@@ -510,15 +515,24 @@ export class AuthProvider {
?.endSession;
}
/**
* Logs the user out of the app.
*
* With revokeTokens (default), the refresh and access tokens are also
* revoked at the IdP (RFC 7009), so a copied token cannot be used anymore.
* This is best effort: the local logout always happens first and never
* waits longer than REVOKE_TIMEOUT_MS for the IdP.
* @param _state Unused.
* @param revokeTokens Revoke the tokens at the IdP.
*/
public async signOut(
_state?: string,
revokeTokens = false,
revokeTokens = true,
): Promise<void> {
try {
if (revokeTokens) {
await this.revokeTokens();
}
const token =
this.tokenSubject.value;
try {
await this.storage.removeItem(
TOKEN_RESPONSE_KEY,
);
@@ -542,6 +556,26 @@ export class AuthProvider {
error,
});
}
if (revokeTokens && token) {
try {
await this.withTimeout(
this.revokeTokenResponse(token),
REVOKE_TIMEOUT_MS,
);
} catch (error) {
/*
* Offline, IdP unreachable or revocation not allowed: the user is
* logged out locally anyway.
*/
this.notify({
action:
AuthActions.RevokeTokensFailed,
error,
});
}
}
}
public async revokeTokens():
@@ -1103,26 +1137,36 @@ export class AuthProvider {
);
}
const configuration =
await this.configuration;
await this.revokeTokenResponse(
token,
);
if (
!configuration.revocationEndpoint
) {
/*
* No revocation endpoint is configured by the backend.
* Remove the local token instead.
*/
await this.storage.removeItem(
TOKEN_RESPONSE_KEY,
);
}
/**
* Revokes the refresh token (if any) and the access token at the IdP.
*
* The refresh token goes first: it is the long-lived one. Both are tried
* even if the first request fails; the first error is rethrown.
* Does nothing if the backend configures no revocation endpoint.
*/
private async revokeTokenResponse(
token: TokenResponse,
): Promise<void> {
const configuration =
await this.configuration;
if (!configuration.revocationEndpoint) {
return;
}
const requests: RevokeTokenRequestJson[] = [];
if (token.refreshToken) {
const refreshRequest:
RevokeTokenRequestJson = {
requests.push({
token:
token.refreshToken,
@@ -1131,20 +1175,10 @@ export class AuthProvider {
client_id:
this.authConfig.client_id,
};
await this.tokenHandler
.performRevokeTokenRequest(
configuration,
new RevokeTokenRequest(
refreshRequest,
),
);
});
}
const accessRequest:
RevokeTokenRequestJson = {
requests.push({
token:
token.accessToken,
@@ -1153,20 +1187,55 @@ export class AuthProvider {
client_id:
this.authConfig.client_id,
};
});
let firstError: unknown;
for (const request of requests) {
try {
await this.tokenHandler
.performRevokeTokenRequest(
configuration,
new RevokeTokenRequest(
accessRequest,
request,
),
);
} catch (error) {
firstError ??= error;
}
}
await this.storage.removeItem(
TOKEN_RESPONSE_KEY,
if (firstError !== undefined) {
throw firstError;
}
}
/**
* Rejects if the promise does not settle within the given time.
*/
private withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timer:
ReturnType<typeof setTimeout> | undefined;
const timeout =
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(
new Error('Token Revocation Timed Out'),
);
}, timeoutMs);
});
return Promise.race([
promise,
timeout,
]).finally(() => {
clearTimeout(timer);
});
}
private async handleAuthorizationCallback(
+2 -2
View File
@@ -18,8 +18,8 @@
// The list of which env maps to which file can be found in `.angular-cli.json`.
export const environment = {
backend_url: 'https://mobile.server.uni-frankfurt.de',
// backend_url: 'http://localhost:3000',
// backend_url: 'https://mobile.server.uni-frankfurt.de',
backend_url: 'http://localhost:3000',
app_host: 'mobile.app.uni-frankfurt.de',
custom_url_scheme: 'de.unifrankfurt.app',
backend_version: '999.0.0',