Skip to content

Commit 3e823d3

Browse files
committed
feat(userInfo): implement persisting user info to support limited tokens
Signed-off-by: Phil Kuang <pkuang@factset.com>
1 parent eacdea1 commit 3e823d3

File tree

16 files changed

+449
-42
lines changed

16 files changed

+449
-42
lines changed

.changeset/cold-comics-rush.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@backstage/backend-app-api': patch
3+
'@backstage/plugin-auth-backend': patch
4+
---
5+
6+
Limited user tokens will no longer include the `ent` field in its payload. Ownership claims will now be fetched from the user info service.

packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@ export class DefaultAuthService implements AuthService {
4949
private readonly publicKeyStore: KeyStore,
5050
) {}
5151

52-
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
53-
async authenticate(token: string): Promise<BackstageCredentials> {
52+
async authenticate(
53+
token: string,
54+
options?: {
55+
allowLimitedAccess?: boolean;
56+
},
57+
): Promise<BackstageCredentials> {
5458
const pluginResult = await this.pluginTokenHandler.verifyToken(token);
5559
if (pluginResult) {
5660
if (pluginResult.limitedUserToken) {
@@ -73,6 +77,13 @@ export class DefaultAuthService implements AuthService {
7377

7478
const userResult = await this.userTokenHandler.verifyToken(token);
7579
if (userResult) {
80+
if (
81+
!options?.allowLimitedAccess &&
82+
this.userTokenHandler.isLimitedUserToken(token)
83+
) {
84+
throw new AuthenticationError('Illegal limited user token');
85+
}
86+
7687
return createCredentialsWithUserPrincipal(
7788
userResult.userEntityRef,
7889
token,

packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ describe('authServiceFactory', () => {
199199
});
200200

201201
it('should issue limited user tokens', async () => {
202+
/* Corresponding private key in case this test needs to be updated in the future:
203+
{
204+
kty: 'EC',
205+
x: 'c9cPvv_S7zETBKDlAa3oOjr7RvyUueIYIak0TRph7mg',
206+
y: 'bKaxDRAWgmEJ9Ix8e85blH_IsnbQxX31x0oQTVwLZ2c',
207+
crv: 'P-256',
208+
d: '2eJlhCDdGx9fxKDL1D9BnY3CCTEKxL60Bkms0hmubmY',
209+
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
210+
alg: 'ES256'
211+
}
212+
*/
202213
server.use(
203214
rest.get(
204215
'http://localhost:7007/api/auth/.well-known/jwks.json',
@@ -208,8 +219,8 @@ describe('authServiceFactory', () => {
208219
keys: [
209220
{
210221
kty: 'EC',
211-
x: '78-Ei1H3nKM23ZpGMMzte2mVoYCcnfnSiLTm1P7vZM0',
212-
y: 'Z9-PjG_EU598tLLUc2f8sCqxT7bjs8WpoV-lHm9GJHY',
222+
x: 'c9cPvv_S7zETBKDlAa3oOjr7RvyUueIYIak0TRph7mg',
223+
y: 'bKaxDRAWgmEJ9Ix8e85blH_IsnbQxX31x0oQTVwLZ2c',
213224
crv: 'P-256',
214225
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
215226
alg: 'ES256',
@@ -234,7 +245,7 @@ describe('authServiceFactory', () => {
234245
const catalogAuth = await tester.get('catalog');
235246

236247
const fullToken =
237-
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA';
248+
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiSmwxVEpycG9VUjR1NENjUE9nalJMeHpEMi1FMGZPR3ptSm81UWI2eS1aN19meG5oVVBEdWVWRE1CS0l6WF9pc0lvSDhlZm9EUFA5bG9aQnpPblB5Z2cifQ.1gVMq1ofO8PzRctu72D6c4IMqXuIabT79WdGEhW6vIrBRs_qhuWAa94Wvz_KYKpBTb2nxgzXJ5OeddeoYApMyQ';
238249

239250
const credentials = await catalogAuth.authenticate(fullToken);
240251
if (!catalogAuth.isPrincipal(credentials, 'user')) {
@@ -256,7 +267,6 @@ describe('authServiceFactory', () => {
256267
const expectedTokenPayload = base64url.encode(
257268
JSON.stringify({
258269
sub: 'user:development/guest',
259-
ent: ['user:development/guest', 'group:default/team-a'],
260270
iat: expectedIssuedAt,
261271
exp: expectedExpiresAt,
262272
}),
@@ -293,6 +303,17 @@ describe('authServiceFactory', () => {
293303
const catalogAuth = await tester.get('catalog');
294304
const permissionAuth = await tester.get('permission');
295305

306+
/* Corresponding private key in case this test needs to be updated in the future:
307+
{
308+
kty: 'EC',
309+
x: 'c9cPvv_S7zETBKDlAa3oOjr7RvyUueIYIak0TRph7mg',
310+
y: 'bKaxDRAWgmEJ9Ix8e85blH_IsnbQxX31x0oQTVwLZ2c',
311+
crv: 'P-256',
312+
d: '2eJlhCDdGx9fxKDL1D9BnY3CCTEKxL60Bkms0hmubmY',
313+
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
314+
alg: 'ES256'
315+
}
316+
*/
296317
server.use(
297318
rest.get(
298319
'http://localhost:7007/api/auth/.well-known/jwks.json',
@@ -302,8 +323,8 @@ describe('authServiceFactory', () => {
302323
keys: [
303324
{
304325
kty: 'EC',
305-
x: '78-Ei1H3nKM23ZpGMMzte2mVoYCcnfnSiLTm1P7vZM0',
306-
y: 'Z9-PjG_EU598tLLUc2f8sCqxT7bjs8WpoV-lHm9GJHY',
326+
x: 'c9cPvv_S7zETBKDlAa3oOjr7RvyUueIYIak0TRph7mg',
327+
y: 'bKaxDRAWgmEJ9Ix8e85blH_IsnbQxX31x0oQTVwLZ2c',
307328
crv: 'P-256',
308329
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
309330
alg: 'ES256',
@@ -341,7 +362,7 @@ describe('authServiceFactory', () => {
341362
});
342363

343364
const fullToken =
344-
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA';
365+
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiSmwxVEpycG9VUjR1NENjUE9nalJMeHpEMi1FMGZPR3ptSm81UWI2eS1aN19meG5oVVBEdWVWRE1CS0l6WF9pc0lvSDhlZm9EUFA5bG9aQnpPblB5Z2cifQ.1gVMq1ofO8PzRctu72D6c4IMqXuIabT79WdGEhW6vIrBRs_qhuWAa94Wvz_KYKpBTb2nxgzXJ5OeddeoYApMyQ';
345366

346367
const credentials = await searchAuth.authenticate(fullToken);
347368
if (!searchAuth.isPrincipal(credentials, 'user')) {

packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,6 @@ describe('UserTokenHandler', () => {
346346
header: { typ: 'vnd.backstage.limited-user', alg: 'ES256' },
347347
payload: {
348348
sub: 'mock',
349-
ent: ['mock'],
350349
iat: 1,
351350
exp: 2,
352351
},
@@ -384,7 +383,6 @@ describe('UserTokenHandler', () => {
384383
new TextEncoder().encode(
385384
JSON.stringify({
386385
sub: parts.payload.sub,
387-
ent: parts.payload.ent,
388386
iat: parts.payload.iat,
389387
exp: parts.payload.exp,
390388
}),

packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ export class UserTokenHandler {
137137
base64url.encode(
138138
JSON.stringify({
139139
sub: payload.sub,
140-
ent: payload.ent,
141140
iat: payload.iat,
142141
exp: payload.exp,
143142
}),

packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ import {
1919
BackstageUserInfo,
2020
coreServices,
2121
createServiceFactory,
22+
DiscoveryService,
2223
BackstageCredentials,
2324
} from '@backstage/backend-plugin-api';
25+
import { ResponseError } from '@backstage/errors';
2426
import { decodeJwt } from 'jose';
2527
import { toInternalBackstageCredentials } from '../auth/helpers';
2628

27-
// TODO: The intention is for this to eventually be replaced by a call to the auth-backend
29+
type Options = {
30+
discovery: DiscoveryService;
31+
};
32+
2833
export class DefaultUserInfoService implements UserInfoService {
34+
private readonly discovery: DiscoveryService;
35+
36+
constructor(options: Options) {
37+
this.discovery = options.discovery;
38+
}
39+
2940
async getUserInfo(
3041
credentials: BackstageCredentials,
3142
): Promise<BackstageUserInfo> {
@@ -36,29 +47,48 @@ export class DefaultUserInfoService implements UserInfoService {
3647
if (!internalCredentials.token) {
3748
throw new Error('User credentials is unexpectedly missing token');
3849
}
39-
const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt(
50+
const { sub: userEntityRef, ent: ownershipEntityRefs } = decodeJwt(
4051
internalCredentials.token,
4152
);
4253

4354
if (typeof userEntityRef !== 'string') {
4455
throw new Error('User entity ref must be a string');
4556
}
57+
58+
// Return user info if it's already available in the token (ie. it is a full token)
4659
if (
47-
!Array.isArray(ownershipEntityRefs) ||
48-
ownershipEntityRefs.some(ref => typeof ref !== 'string')
60+
Array.isArray(ownershipEntityRefs) &&
61+
ownershipEntityRefs.every(ref => typeof ref === 'string')
4962
) {
50-
throw new Error('Ownership entity refs must be an array of strings');
63+
return { userEntityRef, ownershipEntityRefs };
5164
}
5265

53-
return { userEntityRef, ownershipEntityRefs };
66+
const userInfoResp = await fetch(
67+
`${await this.discovery.getBaseUrl('auth')}/v1/userinfo`,
68+
{
69+
headers: {
70+
Authorization: `Bearer ${internalCredentials.token}`,
71+
},
72+
},
73+
);
74+
75+
if (!userInfoResp.ok) {
76+
throw await ResponseError.fromResponse(userInfoResp);
77+
}
78+
79+
const { sub, ent } = await userInfoResp.json();
80+
81+
return { userEntityRef: sub, ownershipEntityRefs: ent };
5482
}
5583
}
5684

5785
/** @public */
5886
export const userInfoServiceFactory = createServiceFactory({
5987
service: coreServices.userInfo,
60-
deps: {},
61-
async factory() {
62-
return new DefaultUserInfoService();
88+
deps: {
89+
discovery: coreServices.discovery,
90+
},
91+
async factory({ discovery }) {
92+
return new DefaultUserInfoService({ discovery });
6393
},
6494
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2024 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// @ts-check
18+
19+
/**
20+
* @param {import('knex').Knex} knex
21+
*/
22+
exports.up = async function up(knex) {
23+
await knex.schema.createTable('user_info', table => {
24+
table.comment('User information');
25+
26+
table
27+
.string('user_entity_ref')
28+
.primary()
29+
.notNullable()
30+
.comment('User entity reference');
31+
32+
table
33+
.text('user_info', 'longtext')
34+
.notNullable()
35+
.comment('User info blob, JSON serialized');
36+
});
37+
};
38+
39+
/**
40+
* @param {import('knex').Knex} knex
41+
*/
42+
exports.down = async function down(knex) {
43+
await knex.schema.dropTable('user_info');
44+
};

plugins/auth-backend/src/identity/TokenFactory.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from 'jose';
2525
import { MemoryKeyStore } from './MemoryKeyStore';
2626
import { TokenFactory } from './TokenFactory';
27+
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
2728
import { tokenTypes } from '@backstage/plugin-auth-node';
2829

2930
const logger = getVoidLogger();
@@ -43,13 +44,18 @@ const entityRef = stringifyEntityRef({
4344
});
4445

4546
describe('TokenFactory', () => {
47+
const mockUserInfoDatabaseHandler = {
48+
addUserInfo: jest.fn().mockResolvedValue(undefined),
49+
} as unknown as UserInfoDatabaseHandler;
50+
4651
it('should issue valid tokens signed by a listed key', async () => {
4752
const keyDurationSeconds = 5;
4853
const factory = new TokenFactory({
4954
issuer: 'my-issuer',
5055
keyStore: new MemoryKeyStore(),
5156
keyDurationSeconds,
5257
logger,
58+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
5359
});
5460

5561
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
@@ -81,6 +87,11 @@ describe('TokenFactory', () => {
8187
verifyResult.payload.iat! + keyDurationSeconds,
8288
);
8389

90+
expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({
91+
userEntityRef: entityRef,
92+
ownershipEntityRefs: [entityRef],
93+
});
94+
8495
// Emulate the reconstruction of a limited user token
8596
const limitedUserToken = [
8697
base64url.encode(
@@ -93,7 +104,6 @@ describe('TokenFactory', () => {
93104
base64url.encode(
94105
JSON.stringify({
95106
sub: verifyResult.payload.sub,
96-
ent: verifyResult.payload.ent,
97107
iat: verifyResult.payload.iat,
98108
exp: verifyResult.payload.exp,
99109
}),
@@ -107,7 +117,6 @@ describe('TokenFactory', () => {
107117
);
108118
expect(verifyProofResult.payload).toEqual({
109119
sub: entityRef,
110-
ent: [entityRef],
111120
iat: expect.any(Number),
112121
exp: expect.any(Number),
113122
});
@@ -125,6 +134,7 @@ describe('TokenFactory', () => {
125134
keyStore: new MemoryKeyStore(),
126135
keyDurationSeconds: 5,
127136
logger,
137+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
128138
});
129139

130140
const token1 = await factory.issueToken({
@@ -170,6 +180,7 @@ describe('TokenFactory', () => {
170180
keyStore: new MemoryKeyStore(),
171181
keyDurationSeconds,
172182
logger,
183+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
173184
});
174185

175186
await expect(() => {
@@ -187,6 +198,7 @@ describe('TokenFactory', () => {
187198
keyDurationSeconds,
188199
logger,
189200
algorithm: '',
201+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
190202
});
191203

192204
await expect(() => {
@@ -202,6 +214,7 @@ describe('TokenFactory', () => {
202214
keyStore: new MemoryKeyStore(),
203215
keyDurationSeconds: 5,
204216
logger,
217+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
205218
});
206219

207220
await expect(() => {
@@ -220,6 +233,7 @@ describe('TokenFactory', () => {
220233
keyStore: new MemoryKeyStore(),
221234
keyDurationSeconds,
222235
logger,
236+
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
223237
});
224238

225239
const token = await factory.issueToken({

0 commit comments

Comments
 (0)