diff --git a/.changeset/rare-ladybugs-invite.md b/.changeset/rare-ladybugs-invite.md new file mode 100644 index 0000000000..4153363b9c --- /dev/null +++ b/.changeset/rare-ladybugs-invite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Fixed the fallback identity population to correctly generate an entity reference for `userEntityRef` if no token is provided. diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 92c76b04b7..a3fc77bc02 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -17,7 +17,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; -import { OAuthHandlers } from './types'; +import { OAuthHandlers, OAuthResponse } from './types'; const mockResponseData = { providerInfo: { @@ -36,6 +36,12 @@ const mockResponseData = { }, }; +function mkTokenBody(payload: unknown): string { + return Buffer.from(JSON.stringify(payload), 'utf8') + .toString('base64') + .replace(/=/g, ''); +} + describe('OAuthAdapter', () => { class MyAuthProvider implements OAuthHandlers { async start() { @@ -249,4 +255,86 @@ describe('OAuthAdapter', () => { 'Refresh token is not supported for provider test-provider', ); }); + + it('correctly populates incomplete identities', async () => { + const mockRefresh = jest.fn, [express.Request]>(); + + const oauthProvider = new OAuthAdapter( + { + refresh: mockRefresh, + start: jest.fn(), + handler: jest.fn(), + } as OAuthHandlers, + { + ...oAuthProviderOptions, + tokenIssuer: { + issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`, + listPublicKeys: async () => ({ keys: [] }), + }, + disableRefresh: false, + isOriginAllowed: () => false, + }, + ); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'token', + }, + query: {}, + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + // Without a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: '', + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(1); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + identity: { + type: 'user', + userEntityRef: 'user:default/foo', + ownershipEntityRefs: [], + }, + }, + }); + + // With a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(2); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + identity: { + type: 'user', + userEntityRef: 'user:my-ns/foo', + ownershipEntityRefs: [], + }, + }, + }); + }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index eb3f7efa42..4d5d507aa1 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -17,6 +17,11 @@ import express from 'express'; import crypto from 'crypto'; import { URL } from 'url'; +import { + ENTITY_DEFAULT_NAMESPACE, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { AuthProviderRouteHandlers, AuthProviderConfig, @@ -243,8 +248,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return prepareBackstageIdentityResponse(identity); } + const userEntityRef = stringifyEntityRef( + parseEntityRef(identity.id, { + defaultKind: 'user', + defaultNamespace: ENTITY_DEFAULT_NAMESPACE, + }), + ); const token = await this.options.tokenIssuer.issueToken({ - claims: { sub: identity.id }, + claims: { sub: userEntityRef }, }); return prepareBackstageIdentityResponse({ ...identity, token });