auth-backend: track backstage session expiration separately
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
The `BackstageIdentityResponse` interface now has an optional `expiresInSeconds` field that can be used to signal session expiration. The `prepareBackstageIdentityResponse` utility will now also read the expiration from the provided token, and include it in the response.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': minor
|
||||
---
|
||||
|
||||
Fixed two bugs in how the `OAuth2Session` type represents the underlying data. The `expiresAt` and `backstageIdentity` are now both optional, since that's what they are in practice. This is not considered a breaking change since it was effectively a bug in the modelling of the state that this type represents, and the type was not used in any other external contract.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': minor
|
||||
---
|
||||
|
||||
The `OAuth` class which is used by all OAuth providers will now consider both the session expiration of both the Backstage identity as well as the upstream identity provider, and refresh the session with either of them is about to expire.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
Added the optional `expiresAt` field that may now be part of a `BackstageIdentityResponse`.
|
||||
@@ -538,10 +538,10 @@ export type OAuth2Session = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -76,6 +76,37 @@ describe('OAuth2', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward backstage identity', async () => {
|
||||
getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
|
||||
backstageIdentity: {
|
||||
token: 'a.b.c',
|
||||
expiresAt: theFuture,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/mock',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const oauth2 = OAuth2.create({
|
||||
configApi: configApi,
|
||||
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
|
||||
oauthRequestApi: new MockOAuthApi(),
|
||||
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
|
||||
});
|
||||
|
||||
await expect(oauth2.getBackstageIdentity()).resolves.toEqual({
|
||||
token: 'a.b.c',
|
||||
expiresAt: theFuture,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/mock',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get refreshed id token', async () => {
|
||||
getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SessionState,
|
||||
SessionApi,
|
||||
BackstageIdentityApi,
|
||||
BackstageUserIdentity,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { OAuth2Session } from './types';
|
||||
@@ -49,10 +50,14 @@ export type OAuth2Response = {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
backstageIdentity: {
|
||||
token: string;
|
||||
expiresInSeconds?: number;
|
||||
identity: BackstageUserIdentity;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
@@ -92,8 +97,11 @@ export default class OAuth2
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: OAuth2Response): OAuth2Session {
|
||||
return {
|
||||
sessionTransform({
|
||||
backstageIdentity,
|
||||
...res
|
||||
}: OAuth2Response): OAuth2Session {
|
||||
const session: OAuth2Session = {
|
||||
...res,
|
||||
providerInfo: {
|
||||
idToken: res.providerInfo.idToken,
|
||||
@@ -102,11 +110,26 @@ export default class OAuth2
|
||||
scopeTransform,
|
||||
res.providerInfo.scope,
|
||||
),
|
||||
expiresAt: new Date(
|
||||
Date.now() + res.providerInfo.expiresInSeconds * 1000,
|
||||
),
|
||||
expiresAt: res.providerInfo.expiresInSeconds
|
||||
? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
if (backstageIdentity) {
|
||||
// TODO(Rugvip): This fallback can be removed a few releases after 1.18. It's there to avoid
|
||||
// breaking deployments that update their frontend before updating their backend.
|
||||
const expInSec =
|
||||
backstageIdentity.expiresInSeconds ??
|
||||
res.providerInfo.expiresInSeconds;
|
||||
session.backstageIdentity = {
|
||||
token: backstageIdentity.token,
|
||||
identity: backstageIdentity.identity,
|
||||
expiresAt: expInSec
|
||||
? new Date(Date.now() + expInSec * 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return session;
|
||||
},
|
||||
popupOptions,
|
||||
});
|
||||
@@ -116,9 +139,21 @@ export default class OAuth2
|
||||
defaultScopes: new Set(defaultScopes),
|
||||
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
|
||||
sessionShouldRefresh: (session: OAuth2Session) => {
|
||||
const expiresInSec =
|
||||
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
|
||||
return expiresInSec < 60 * 5;
|
||||
// TODO(Rugvip): Optimize to use separate checks for provider vs backstage session expiration
|
||||
let min = Infinity;
|
||||
if (session.providerInfo?.expiresAt) {
|
||||
min = Math.min(
|
||||
min,
|
||||
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000,
|
||||
);
|
||||
}
|
||||
if (session.backstageIdentity?.expiresAt) {
|
||||
min = Math.min(
|
||||
min,
|
||||
(session.backstageIdentity.expiresAt.getTime() - Date.now()) / 1000,
|
||||
);
|
||||
}
|
||||
return min < 60 * 5;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export type OAuth2Session = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
@@ -214,6 +214,7 @@ export type BackstageIdentityApi = {
|
||||
// @public
|
||||
export type BackstageIdentityResponse = {
|
||||
token: string;
|
||||
expiresAt?: Date;
|
||||
identity: BackstageUserIdentity;
|
||||
};
|
||||
|
||||
|
||||
@@ -226,6 +226,11 @@ export type BackstageIdentityResponse = {
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* The time at which the token expires. If not set, it can be assumed that the token does not expire.
|
||||
*/
|
||||
expiresAt?: Date;
|
||||
|
||||
/**
|
||||
* Identity information derived from the token.
|
||||
*/
|
||||
|
||||
@@ -37,7 +37,7 @@ export function adaptLegacyOAuthHandler(
|
||||
scope: result.session.scope,
|
||||
id_token: result.session.idToken,
|
||||
token_type: result.session.tokenType,
|
||||
expires_in: result.session.expiresInSeconds,
|
||||
expires_in: result.session.expiresInSeconds!,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
|
||||
@@ -39,7 +39,7 @@ export function adaptLegacyOAuthSignInResolver(
|
||||
scope: input.result.session.scope,
|
||||
id_token: input.result.session.idToken,
|
||||
token_type: input.result.session.tokenType,
|
||||
expires_in: input.result.session.expiresInSeconds,
|
||||
expires_in: input.result.session.expiresInSeconds!,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,6 +30,10 @@ describe('MicrosoftAuthProvider', () => {
|
||||
const state = Buffer.from(
|
||||
`nonce=${encodeURIComponent(nonce)}&env=development`,
|
||||
).toString('hex');
|
||||
const mockBackstageToken = `header.${Buffer.from(
|
||||
JSON.stringify({ sub: 'user:default/mock' }),
|
||||
'utf8',
|
||||
).toString('base64')}.backstage`;
|
||||
|
||||
const server = setupServer();
|
||||
const microsoftApi = new FakeMicrosoftAPI();
|
||||
@@ -64,10 +68,9 @@ describe('MicrosoftAuthProvider', () => {
|
||||
resolverContext: {
|
||||
issueToken: jest.fn(),
|
||||
findCatalogUser: jest.fn(),
|
||||
signInWithCatalogUser: _ =>
|
||||
Promise.resolve({
|
||||
token: 'header.e30K.backstage',
|
||||
}),
|
||||
signInWithCatalogUser: async _ => ({
|
||||
token: mockBackstageToken,
|
||||
}),
|
||||
} as AuthResolverContext,
|
||||
}) as AuthProviderRouteHandlers;
|
||||
|
||||
@@ -209,8 +212,12 @@ describe('MicrosoftAuthProvider', () => {
|
||||
displayName: 'Conrad',
|
||||
},
|
||||
backstageIdentity: {
|
||||
token: 'header.e30K.backstage',
|
||||
identity: { type: 'user', ownershipEntityRefs: [] },
|
||||
token: mockBackstageToken,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/mock',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -330,8 +337,12 @@ describe('MicrosoftAuthProvider', () => {
|
||||
displayName: 'Conrad',
|
||||
},
|
||||
backstageIdentity: {
|
||||
token: 'header.e30K.backstage',
|
||||
identity: { type: 'user', ownershipEntityRefs: [] },
|
||||
token: mockBackstageToken,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/mock',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -430,7 +441,7 @@ describe('MicrosoftAuthProvider', () => {
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backstageIdentity: expect.objectContaining({
|
||||
token: 'header.e30K.backstage',
|
||||
token: mockBackstageToken,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -100,6 +100,7 @@ export type AuthResolverContext = {
|
||||
|
||||
// @public
|
||||
export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
expiresInSeconds?: number;
|
||||
identity: BackstageUserIdentity;
|
||||
}
|
||||
|
||||
@@ -377,7 +378,7 @@ export interface OAuthSession {
|
||||
// (undocumented)
|
||||
accessToken: string;
|
||||
// (undocumented)
|
||||
expiresInSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
// (undocumented)
|
||||
idToken?: string;
|
||||
// (undocumented)
|
||||
|
||||
@@ -23,7 +23,37 @@ function mkToken(payload: unknown) {
|
||||
}
|
||||
|
||||
describe('prepareBackstageIdentityResponse', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('parses a complete token to determine the identity', () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(5000);
|
||||
|
||||
const token = mkToken({ sub: 'k:ns/n', ent: ['k:ns/o'], exp: 1005 });
|
||||
expect(
|
||||
prepareBackstageIdentityResponse({
|
||||
token,
|
||||
}),
|
||||
).toEqual({
|
||||
token,
|
||||
expiresInSeconds: 1000,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'k:ns/n',
|
||||
ownershipEntityRefs: ['k:ns/o'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject tokens without subject', () => {
|
||||
const token = mkToken({});
|
||||
expect(() =>
|
||||
prepareBackstageIdentityResponse({
|
||||
token,
|
||||
}),
|
||||
).toThrow('Identity response must return a token with subject claim');
|
||||
});
|
||||
|
||||
it('should treat expiration as optional', () => {
|
||||
const token = mkToken({ sub: 'k:ns/n', ent: ['k:ns/o'] });
|
||||
expect(
|
||||
prepareBackstageIdentityResponse({
|
||||
@@ -38,4 +68,15 @@ describe('prepareBackstageIdentityResponse', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject tokens with negative expiration', () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(5000);
|
||||
|
||||
const token = mkToken({ sub: 'k:ns/n', ent: ['k:ns/o'], exp: 1 });
|
||||
expect(() =>
|
||||
prepareBackstageIdentityResponse({
|
||||
token,
|
||||
}),
|
||||
).toThrow('Identity response must not return an expired token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,14 +39,28 @@ export function prepareBackstageIdentityResponse(
|
||||
throw new InputError(`Identity response must return a token`);
|
||||
}
|
||||
|
||||
const { sub, ent } = parseJwtPayload(result.token);
|
||||
const { sub, ent = [], exp: expStr } = parseJwtPayload(result.token);
|
||||
if (!sub) {
|
||||
throw new InputError(
|
||||
`Identity response must return a token with subject claim`,
|
||||
);
|
||||
}
|
||||
|
||||
const expAt = Number(expStr);
|
||||
|
||||
// Default to 1 hour if no expiration is set, in particular to make testing simpler
|
||||
const exp = expAt ? Math.round(expAt - Date.now() / 1000) : undefined;
|
||||
if (exp && exp < 0) {
|
||||
throw new InputError(`Identity response must not return an expired token`);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
expiresInSeconds: exp,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
ownershipEntityRefs: ent ?? [],
|
||||
ownershipEntityRefs: ent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface OAuthSession {
|
||||
tokenType: string;
|
||||
idToken?: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface BackstageSignInResult {
|
||||
* @public
|
||||
*/
|
||||
export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
/**
|
||||
* The number of seconds until the token expires. If not set, it can be assumed that the token does not expire.
|
||||
*/
|
||||
expiresInSeconds?: number;
|
||||
|
||||
/**
|
||||
* A plaintext description of the identity that is encapsulated within the token.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user