can get non-microsoft graph tokens via refresh
Next up will be to make it possible to _request_ refreshable tokens via `getAccessToken`, accounting for the fact that the `offline_access` scope does not appear in the auth-backend response when it is requested -- instead the response has a set-cookie header. Signed-off-by: Jamie Klassen <jklassen@vmware.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/core-app-api': minor
|
||||
---
|
||||
|
||||
The `AuthConnector` interface now supports specifying a set of scopes when
|
||||
refreshing a session. The `DefaultAuthConnector` implementation passes the
|
||||
`scope` query parameter to the auth-backend plugin appropriately. The
|
||||
`RefreshingAuthSessionManager` passes any scopes in its `GetSessionRequest`
|
||||
appropriately.
|
||||
+16
-50
@@ -34,59 +34,20 @@ describe('MicrosoftAuth', () => {
|
||||
),
|
||||
});
|
||||
|
||||
const toHaveJWTClaims = function toHaveJWTClaims(
|
||||
this: jest.MatcherContext,
|
||||
received: string,
|
||||
expected: Record<string, any>,
|
||||
): jest.CustomMatcherResult {
|
||||
let parsedClaims: Record<string, any>;
|
||||
try {
|
||||
parsedClaims = JSON.parse(
|
||||
Buffer.from(received.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () =>
|
||||
`Expected JWT with claims: ${this.utils.printExpected(
|
||||
expected,
|
||||
)}\nReceived invalid JWT ${this.utils.printReceived(
|
||||
received,
|
||||
)}\nError: ${e}`,
|
||||
};
|
||||
}
|
||||
const expectedResult = expect.objectContaining(expected);
|
||||
return {
|
||||
pass: this.equals(parsedClaims, expectedResult),
|
||||
message: () =>
|
||||
`Expected JWT with claims: ${this.utils.printExpected(
|
||||
expected,
|
||||
)}\nReceived JWT with claims: ${this.utils.printReceived(
|
||||
parsedClaims,
|
||||
)}\n\n${this.utils.diff(expectedResult, parsedClaims)}`,
|
||||
};
|
||||
};
|
||||
expect.extend({ toHaveJWTClaims });
|
||||
|
||||
describe('with a refresh token', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://backstage.test/api/auth/microsoft/refresh',
|
||||
(req, res, ctx) => {
|
||||
const scope =
|
||||
req.url.searchParams.get('scope') ||
|
||||
'openid profile email User.Read';
|
||||
async (req, res, ctx) => {
|
||||
const scopeParam = req.url.searchParams.get('scope');
|
||||
return res(
|
||||
ctx.json({
|
||||
providerInfo: {
|
||||
accessToken: `header.${Buffer.from(
|
||||
JSON.stringify({
|
||||
aud: '00000003-0000-0000-c000-000000000000',
|
||||
scp: 'openid profile email User.Read',
|
||||
}),
|
||||
).toString('base64')}.signature`,
|
||||
scope,
|
||||
accessToken: scopeParam
|
||||
? 'tokenForOtherResource'
|
||||
: 'tokenForGrantScopes',
|
||||
scope: scopeParam || 'grant-resource/scope',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -95,13 +56,18 @@ describe('MicrosoftAuth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gets access token for Microsoft Graph', async () => {
|
||||
it('gets access token with requested scopes for grant', async () => {
|
||||
const accessToken = await microsoftAuth.getAccessToken();
|
||||
|
||||
expect(accessToken).toHaveJWTClaims({
|
||||
aud: '00000003-0000-0000-c000-000000000000',
|
||||
scp: 'openid profile email User.Read',
|
||||
});
|
||||
expect(accessToken).toEqual('tokenForGrantScopes');
|
||||
});
|
||||
|
||||
it('gets access token for other consented scopes besides those directly granted', async () => {
|
||||
const accessToken = await microsoftAuth.getAccessToken(
|
||||
'azure-resource/scope',
|
||||
);
|
||||
|
||||
expect(accessToken).toEqual('tokenForOtherResource');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -59,22 +59,22 @@ describe('DefaultAuthConnector', () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should refresh a session', async () => {
|
||||
it('should refresh a session with scope', async () => {
|
||||
server.use(
|
||||
rest.get('*', (_req, res, ctx) =>
|
||||
rest.get('*', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
idToken: 'mock-id-token',
|
||||
accessToken: 'mock-access-token',
|
||||
scopes: 'a b c',
|
||||
scopes: req.url.searchParams.get('scope') || 'default-scope',
|
||||
expiresInSeconds: '60',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const helper = new DefaultAuthConnector<any>(defaultOptions);
|
||||
const session = await helper.refreshSession();
|
||||
const connector = new DefaultAuthConnector<any>(defaultOptions);
|
||||
const session = await connector.refreshSession(new Set(['a', 'b', 'c']));
|
||||
expect(session.idToken).toBe('mock-id-token');
|
||||
expect(session.accessToken).toBe('mock-access-token');
|
||||
expect(session.scopes).toEqual(new Set(['a', 'b', 'c']));
|
||||
@@ -89,8 +89,8 @@ describe('DefaultAuthConnector', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const helper = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
const connector = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(connector.refreshSession()).rejects.toThrow(
|
||||
'Auth refresh request failed, Error: Network NOPE',
|
||||
);
|
||||
});
|
||||
@@ -98,19 +98,19 @@ describe('DefaultAuthConnector', () => {
|
||||
it('should handle failure response when refreshing session', async () => {
|
||||
server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE'))));
|
||||
|
||||
const helper = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
const connector = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(connector.refreshSession()).rejects.toThrow(
|
||||
'Auth refresh request failed, NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if popup was rejected', async () => {
|
||||
const mockOauth = new MockOAuthApi();
|
||||
const helper = new DefaultAuthConnector({
|
||||
const connector = new DefaultAuthConnector({
|
||||
...defaultOptions,
|
||||
oauthRequestApi: mockOauth,
|
||||
});
|
||||
const promise = helper.createSession({ scopes: new Set(['a', 'b']) });
|
||||
const promise = connector.createSession({ scopes: new Set(['a', 'b']) });
|
||||
await mockOauth.rejectAll();
|
||||
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
|
||||
});
|
||||
@@ -125,12 +125,12 @@ describe('DefaultAuthConnector', () => {
|
||||
scopes: 'a b',
|
||||
expiresInSeconds: 3600,
|
||||
});
|
||||
const helper = new DefaultAuthConnector({
|
||||
const connector = new DefaultAuthConnector({
|
||||
...defaultOptions,
|
||||
oauthRequestApi: mockOauth,
|
||||
});
|
||||
|
||||
const sessionPromise = helper.createSession({
|
||||
const sessionPromise = connector.createSession({
|
||||
scopes: new Set(['a', 'b']),
|
||||
});
|
||||
|
||||
@@ -153,13 +153,13 @@ describe('DefaultAuthConnector', () => {
|
||||
const popupSpy = jest
|
||||
.spyOn(loginPopup, 'showLoginPopup')
|
||||
.mockResolvedValue('my-session');
|
||||
const helper = new DefaultAuthConnector({
|
||||
const connector = new DefaultAuthConnector({
|
||||
...defaultOptions,
|
||||
oauthRequestApi: new MockOAuthApi(),
|
||||
sessionTransform: str => str,
|
||||
});
|
||||
|
||||
const sessionPromise = helper.createSession({
|
||||
const sessionPromise = connector.createSession({
|
||||
scopes: new Set(),
|
||||
instantPopup: true,
|
||||
});
|
||||
@@ -174,13 +174,13 @@ describe('DefaultAuthConnector', () => {
|
||||
const popupSpy = jest
|
||||
.spyOn(loginPopup, 'showLoginPopup')
|
||||
.mockResolvedValue({ scopes: '' });
|
||||
const helper = new DefaultAuthConnector({
|
||||
const connector = new DefaultAuthConnector({
|
||||
...defaultOptions,
|
||||
joinScopes: scopes => `-${[...scopes].join('')}-`,
|
||||
oauthRequestApi: mockOauth,
|
||||
});
|
||||
|
||||
helper.createSession({ scopes: new Set(['a', 'b']) });
|
||||
connector.createSession({ scopes: new Set(['a', 'b']) });
|
||||
|
||||
await mockOauth.triggerAll();
|
||||
|
||||
|
||||
@@ -126,9 +126,12 @@ export class DefaultAuthConnector<AuthSession>
|
||||
return this.authRequester(options.scopes);
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<any> {
|
||||
async refreshSession(scopes?: Set<string>): Promise<any> {
|
||||
const res = await fetch(
|
||||
await this.buildUrl('/refresh', { optional: true }),
|
||||
await this.buildUrl('/refresh', {
|
||||
optional: true,
|
||||
...(scopes && { scope: this.joinScopesFunc(scopes) }),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
|
||||
@@ -25,6 +25,6 @@ export type CreateSessionOptions = {
|
||||
*/
|
||||
export type AuthConnector<AuthSession> = {
|
||||
createSession(options: CreateSessionOptions): Promise<AuthSession>;
|
||||
refreshSession(): Promise<AuthSession>;
|
||||
refreshSession(scopes?: Set<string>): Promise<AuthSession>;
|
||||
removeSession(): Promise<void>;
|
||||
};
|
||||
|
||||
+3
-3
@@ -47,7 +47,7 @@ describe('RefreshingAuthSessionManager', () => {
|
||||
await manager.getSession({});
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(refreshSession).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSession).toHaveBeenCalledWith(undefined);
|
||||
expect(stateSubscriber.mock.calls).toEqual([
|
||||
[SessionState.SignedOut],
|
||||
[SessionState.SignedIn],
|
||||
@@ -103,7 +103,7 @@ describe('RefreshingAuthSessionManager', () => {
|
||||
|
||||
await manager.getSession({ scopes: new Set(['a']) });
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSession).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSession).toHaveBeenCalledWith(new Set(['a']));
|
||||
|
||||
await manager.getSession({ scopes: new Set(['a']) });
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
@@ -134,7 +134,7 @@ describe('RefreshingAuthSessionManager', () => {
|
||||
|
||||
expect(await manager.getSession({ optional: true })).toBe(undefined);
|
||||
expect(createSession).toHaveBeenCalledTimes(0);
|
||||
expect(refreshSession).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSession).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should forward option to instantly show auth popup and not attempt refresh', async () => {
|
||||
|
||||
@@ -73,7 +73,9 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshedSession = await this.collapsedSessionRefresh();
|
||||
const refreshedSession = await this.collapsedSessionRefresh(
|
||||
options.scopes,
|
||||
);
|
||||
const currentScopes = this.sessionScopesFunc(this.currentSession!);
|
||||
const refreshedScopes = this.sessionScopesFunc(refreshedSession);
|
||||
if (hasScopes(refreshedScopes, currentScopes)) {
|
||||
@@ -97,7 +99,7 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
// already had an existing session.
|
||||
if (!this.currentSession && !options.instantPopup) {
|
||||
try {
|
||||
const newSession = await this.collapsedSessionRefresh();
|
||||
const newSession = await this.collapsedSessionRefresh(options.scopes);
|
||||
this.currentSession = newSession;
|
||||
// The session might not have the scopes requested so go back and check again
|
||||
return this.getSession(options);
|
||||
@@ -130,12 +132,12 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
return this.stateTracker.sessionState$();
|
||||
}
|
||||
|
||||
private async collapsedSessionRefresh(): Promise<T> {
|
||||
private async collapsedSessionRefresh(scopes?: Set<string>): Promise<T> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.connector.refreshSession();
|
||||
this.refreshPromise = this.connector.refreshSession(scopes);
|
||||
|
||||
try {
|
||||
const session = await this.refreshPromise;
|
||||
|
||||
Reference in New Issue
Block a user