Skip to content

Commit

Permalink
auth-backend: store github oauth token in cookie and use for refresh
Browse files Browse the repository at this point in the history
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
  • Loading branch information
Rugvip committed Feb 2, 2022
1 parent 9d75a93 commit 648606b
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 28 deletions.
5 changes: 5 additions & 0 deletions .changeset/strong-taxis-refuse.md
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---

Added support for storing static GitHub access tokens in cookies and using them to refresh the Backstage session.
2 changes: 1 addition & 1 deletion plugins/auth-backend/api-report.md
Expand Up @@ -735,6 +735,6 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:98:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
```
62 changes: 60 additions & 2 deletions plugins/auth-backend/src/providers/github/provider.test.ts
Expand Up @@ -98,6 +98,7 @@ describe('GithubAuthProvider', () => {
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'read:scope',
expiresInSeconds: 3600,
},
profile: {
email: 'jimmymarkum@gmail.com',
Expand Down Expand Up @@ -143,6 +144,7 @@ describe('GithubAuthProvider', () => {
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'read:scope',
expiresInSeconds: 3600,
},
profile: {
displayName: 'Jimmy Markum',
Expand Down Expand Up @@ -186,6 +188,7 @@ describe('GithubAuthProvider', () => {
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
scope: 'read:scope',
expiresInSeconds: 3600,
},
profile: {
displayName: 'jimmymarkum',
Expand Down Expand Up @@ -230,6 +233,7 @@ describe('GithubAuthProvider', () => {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
scope: 'read:user',
expiresInSeconds: 3600,
},
profile: {
displayName: 'Dave Boyle',
Expand Down Expand Up @@ -316,7 +320,7 @@ describe('GithubAuthProvider', () => {
],
});

const result = await provider.refresh({} as any);
const result = await provider.refresh({ scope: 'actual-scope' } as any);

expect(result).toEqual({
response: {
Expand All @@ -332,11 +336,65 @@ describe('GithubAuthProvider', () => {
providerInfo: {
accessToken: 'a.b.c',
expiresInSeconds: 123,
scope: 'read_user',
scope: 'actual-scope',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});

mockRefreshToken.mockRestore();
mockUserProfile.mockRestore();
});

it('should use access token as refresh token', async () => {
const mockUserProfile = jest.spyOn(
helpers,
'executeFetchUserProfileStrategy',
) as unknown as jest.MockedFunction<() => Promise<PassportProfile>>;

mockUserProfile.mockResolvedValueOnce({
id: 'mockid',
username: 'mockuser',
provider: 'github',
displayName: 'Mocked User',
emails: [
{
value: 'mockuser@gmail.com',
},
],
});

const result = await provider.refresh({
refreshToken: 'access-token.le-token',
scope: 'the-scope',
} as any);

expect(mockUserProfile).toHaveBeenCalledTimes(1);
expect(mockUserProfile).toHaveBeenCalledWith(
expect.anything(),
'le-token',
);
expect(result).toEqual({
response: {
backstageIdentity: {
id: 'mockuser',
token: 'token-for-user:default/mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'le-token',
expiresInSeconds: 3600,
scope: 'the-scope',
},
},
refreshToken: 'access-token.le-token',
});

mockUserProfile.mockRestore();
});
});
});
106 changes: 81 additions & 25 deletions plugins/auth-backend/src/providers/github/provider.ts
Expand Up @@ -41,11 +41,15 @@ import {
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
OAuthResponse,
} from '../../lib/oauth';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';

const ACCESS_TOKEN_PREFIX = 'access-token.';

// TODO(Rugvip): Auth providers need a way to access this in a less hardcoded way
const BACKSTAGE_SESSION_EXPIRATION = 3600;

type PrivateInfo = {
refreshToken?: string;
};
Expand Down Expand Up @@ -123,31 +127,69 @@ export class GithubAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);

let refreshToken = privateInfo.refreshToken;

// If we do not have a real refresh token and we have a non-expiring
// access token, then we use that as our refresh token.
if (!refreshToken && !result.params.expires_in) {
refreshToken = ACCESS_TOKEN_PREFIX + result.accessToken;
}

return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
refreshToken,
};
}

async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
// We've enable persisting scope in the OAuth provider, so scope here will
// be whatever was stored in the cookie
const { scope, refreshToken } = req;

// This is the OAuth App flow. A non-expiring access token is stored in the
// refresh token cookie. We use that token to fetch the user profile and
// refresh the Backstage session when needed.
if (refreshToken?.startsWith(ACCESS_TOKEN_PREFIX)) {
const accessToken = refreshToken.slice(ACCESS_TOKEN_PREFIX.length);

const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
accessToken,
).catch(error => {
if (error.oauthError?.statusCode === 401) {
throw new Error('Invalid access token');
}
throw error;
});

return {
response: await this.handleResult({
fullProfile,
params: { scope },
accessToken,
}),
refreshToken,
};
}

// This is the App flow, which is close to a standard OAuth refresh flow. It has a
// pretty long session expiration, and it also ignores the requested scope, instead
// just allowing access to whatever is configured as part of the app installation.
const result = await executeRefreshTokenStrategy(
this._strategy,
accessToken,
refreshToken,
scope,
);

return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
result.accessToken,
),
params: { ...result.params, scope },
accessToken: result.accessToken,
}),
refreshToken,
refreshToken: result.refreshToken,
};
}

Expand All @@ -160,27 +202,41 @@ export class GithubAuthProvider implements OAuthHandlers {
const { profile } = await this.authHandler(result, context);

const expiresInStr = result.params.expires_in;
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
},
profile,
};
let expiresInSeconds =
expiresInStr === undefined ? undefined : Number(expiresInStr);

let backstageIdentity = undefined;

if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
backstageIdentity = await this.signInResolver(
{
result,
profile,
},
context,
);

// GitHub sessions last longer than Backstage sessions, so if we're using
// GitHub for sign-in, then we need to expire the sessions earlier
if (expiresInSeconds) {
expiresInSeconds = Math.min(
expiresInSeconds,
BACKSTAGE_SESSION_EXPIRATION,
);
} else {
expiresInSeconds = BACKSTAGE_SESSION_EXPIRATION;
}
}

return response;
return {
backstageIdentity,
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds,
},
profile,
};
}
}

Expand Down

0 comments on commit 648606b

Please sign in to comment.