feat: move out the database saving of the userInfo into the CatalogContext

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-07-07 17:26:54 +02:00
parent f78a03b57b
commit 94f1c640a8
14 changed files with 142 additions and 79 deletions
@@ -0,0 +1,42 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('user_info', table => {
table.renameColumn('exp', 'updated_at');
table
.timestamp('created_at')
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the user info');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('user_info', table => {
table.renameColumn('updated_at', 'exp');
table.dropColumn('created_at');
});
};
+1 -2
View File
@@ -86,6 +86,7 @@ describe('authPlugin', () => {
});
const refreshRes = await request(server).post('/api/auth/guest/refresh');
expect(refreshRes.status).toBe(200);
expect(refreshRes.body).toMatchObject({
backstageIdentity: {
@@ -110,7 +111,6 @@ describe('authPlugin', () => {
claims: {
sub: expectedIdentity.userEntityRef,
ent: expectedIdentity.ownershipEntityRefs,
exp: expect.any(Number),
},
});
});
@@ -160,7 +160,6 @@ describe('authPlugin', () => {
claims: {
sub: expectedIdentity.userEntityRef,
ent: expectedIdentity.ownershipEntityRefs,
exp: expect.any(Number),
},
});
});
@@ -19,6 +19,7 @@ import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Knex } from 'knex';
import { UserInfoDatabase } from './UserInfoDatabase';
import { AuthDatabase } from './AuthDatabase';
import { DateTime } from 'luxon';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
@@ -62,7 +63,6 @@ describe('UserInfoDatabase', () => {
claims: {
sub: 'user:default/foo',
ent: ['group:default/foo-group', 'group:default/bar'],
exp: 1234567890,
},
};
@@ -71,10 +71,12 @@ describe('UserInfoDatabase', () => {
const savedUserInfo = await knex('user_info')
.where('user_entity_ref', 'user:default/foo')
.first();
expect(savedUserInfo).toEqual({
user_entity_ref: 'user:default/foo',
user_info: JSON.stringify(userInfo),
exp: expect.anything(),
updated_at: expect.any(String),
created_at: expect.any(String),
});
userInfo.claims.ent = ['group:default/group1', 'group:default/group2'];
@@ -83,10 +85,12 @@ describe('UserInfoDatabase', () => {
const updatedUserInfo = await knex('user_info')
.where('user_entity_ref', 'user:default/foo')
.first();
expect(updatedUserInfo).toEqual({
user_entity_ref: 'user:default/foo',
user_info: JSON.stringify(userInfo),
exp: expect.anything(),
updated_at: expect.any(String),
created_at: expect.any(String),
});
});
@@ -95,14 +99,13 @@ describe('UserInfoDatabase', () => {
claims: {
sub: 'user:default/backstage-user',
ent: ['group:default/group1', 'group:default/group2'],
exp: 1234567890,
},
};
await knex('user_info').insert({
user_entity_ref: 'user:default/backstage-user',
user_info: JSON.stringify(userInfo),
exp: knex.fn.now(),
updated_at: DateTime.now().toSQL({ includeOffset: false }),
});
const savedUserInfo = await dbHandler.getUserInfo(
@@ -17,19 +17,19 @@
import { DateTime } from 'luxon';
import { Knex } from 'knex';
import { BackstageTokenPayload } from '../identity/TokenFactory';
import { AuthDatabase } from './AuthDatabase';
import { JsonObject } from '@backstage/types';
const TABLE = 'user_info';
type Row = {
user_entity_ref: string;
user_info: string;
exp: string;
updated_at: string;
};
type UserInfo = {
claims: Omit<BackstageTokenPayload, 'aud' | 'iat' | 'iss' | 'uip'>;
claims: JsonObject;
};
export class UserInfoDatabase {
@@ -40,9 +40,7 @@ export class UserInfoDatabase {
.insert({
user_entity_ref: userInfo.claims.sub as string,
user_info: JSON.stringify(userInfo),
exp: DateTime.fromSeconds(userInfo.claims.exp as number, {
zone: 'utc',
}).toSQL({ includeOffset: false }),
updated_at: DateTime.now().toSQL({ includeOffset: false }),
})
.onConflict('user_entity_ref')
.merge();
@@ -18,8 +18,6 @@ import { createLocalJWKSet, jwtVerify } from 'jose';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { StaticKeyStore } from './StaticKeyStore';
import { mockServices } from '@backstage/backend-test-utils';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { omit } from 'lodash';
const logger = mockServices.logger.mock();
const entityRef = stringifyEntityRef({
@@ -29,10 +27,6 @@ const entityRef = stringifyEntityRef({
});
describe('StaticTokenIssuer', () => {
const mockUserInfo = {
addUserInfo: jest.fn().mockResolvedValue(undefined),
} as unknown as UserInfoDatabase;
const staticKeyStore = {
listKeys: () => {
return Promise.resolve({
@@ -85,7 +79,6 @@ describe('StaticTokenIssuer', () => {
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
userInfo: mockUserInfo,
},
staticKeyStore as unknown as StaticKeyStore,
);
@@ -119,9 +112,6 @@ describe('StaticTokenIssuer', () => {
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
expect(mockUserInfo.addUserInfo).toHaveBeenCalledWith({
claims: omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']),
});
});
it('should issue valid tokens with omitted claims', async () => {
@@ -131,7 +121,6 @@ describe('StaticTokenIssuer', () => {
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
userInfo: mockUserInfo,
omitClaimsFromToken: ['ent'],
},
staticKeyStore as unknown as StaticKeyStore,
@@ -165,11 +154,5 @@ describe('StaticTokenIssuer', () => {
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
expect(mockUserInfo.addUserInfo).toHaveBeenCalledWith({
claims: {
...omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']),
ent: [entityRef],
},
});
});
});
@@ -22,7 +22,6 @@ import {
BackstageSignInResult,
TokenParams,
} from '@backstage/plugin-auth-node';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { issueUserToken } from './issueUserToken';
export type Config = {
@@ -42,7 +41,6 @@ export type Options = {
* A list of claims to omit from issued tokens and only store in the user info database
*/
omitClaimsFromToken?: string[];
userInfo: UserInfoDatabase;
};
/**
@@ -55,7 +53,6 @@ export class StaticTokenIssuer implements TokenIssuer {
private readonly keyStore: StaticKeyStore;
private readonly sessionExpirationSeconds: number;
private readonly omitClaimsFromToken?: string[];
private readonly userInfo: UserInfoDatabase;
public constructor(options: Options, keyStore: StaticKeyStore) {
this.issuer = options.issuer;
@@ -63,10 +60,11 @@ export class StaticTokenIssuer implements TokenIssuer {
this.sessionExpirationSeconds = options.sessionExpirationSeconds;
this.keyStore = keyStore;
this.omitClaimsFromToken = options.omitClaimsFromToken;
this.userInfo = options.userInfo;
}
public async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
public async issueToken(
params: TokenParams & { claims: { ent: string[] } },
): Promise<BackstageSignInResult> {
const key = await this.getSigningKey();
return issueUserToken({
@@ -76,7 +74,6 @@ export class StaticTokenIssuer implements TokenIssuer {
logger: this.logger,
omitClaimsFromToken: this.omitClaimsFromToken,
params,
userInfo: this.userInfo,
});
}
@@ -21,10 +21,8 @@ import {
decodeProtectedHeader,
jwtVerify,
} from 'jose';
import { omit } from 'lodash';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { tokenTypes } from '@backstage/plugin-auth-node';
import { mockServices } from '@backstage/backend-test-utils';
@@ -45,10 +43,6 @@ const entityRef = stringifyEntityRef({
});
describe('TokenFactory', () => {
const mockUserInfoDatabaseHandler = {
addUserInfo: jest.fn().mockResolvedValue(undefined),
} as unknown as UserInfoDatabase;
it('should issue valid tokens signed by a listed key', async () => {
const keyDurationSeconds = 5;
const factory = new TokenFactory({
@@ -56,7 +50,6 @@ describe('TokenFactory', () => {
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
userInfo: mockUserInfoDatabaseHandler,
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
@@ -93,10 +86,6 @@ describe('TokenFactory', () => {
verifyResult.payload.iat! + keyDurationSeconds,
);
expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({
claims: omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']),
});
// Emulate the reconstruction of a limited user token
const limitedUserToken = [
base64url.encode(
@@ -139,14 +128,13 @@ describe('TokenFactory', () => {
keyStore: new MemoryKeyStore(),
keyDurationSeconds: 5,
logger,
userInfo: mockUserInfoDatabaseHandler,
});
const { token: token1 } = await factory.issueToken({
claims: { sub: entityRef },
claims: { sub: entityRef, ent: [entityRef] },
});
const { token: token2 } = await factory.issueToken({
claims: { sub: entityRef },
claims: { sub: entityRef, ent: [entityRef] },
});
expect(jwtKid(token1)).toBe(jwtKid(token2));
@@ -165,7 +153,7 @@ describe('TokenFactory', () => {
});
const { token: token3 } = await factory.issueToken({
claims: { sub: entityRef },
claims: { sub: entityRef, ent: [entityRef] },
});
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
@@ -185,12 +173,11 @@ describe('TokenFactory', () => {
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
userInfo: mockUserInfoDatabaseHandler,
});
await expect(() => {
return factory.issueToken({
claims: { sub: 'UserId' },
claims: { sub: 'UserId', ent: [entityRef] },
});
}).rejects.toThrow();
});
@@ -203,12 +190,11 @@ describe('TokenFactory', () => {
keyDurationSeconds,
logger,
algorithm: '',
userInfo: mockUserInfoDatabaseHandler,
});
await expect(() => {
return factory.issueToken({
claims: { sub: 'UserId' },
claims: { sub: 'UserId', ent: [entityRef] },
});
}).rejects.toThrow();
});
@@ -219,7 +205,6 @@ describe('TokenFactory', () => {
keyStore: new MemoryKeyStore(),
keyDurationSeconds: 5,
logger,
userInfo: mockUserInfoDatabaseHandler,
});
await expect(() => {
@@ -238,7 +223,6 @@ describe('TokenFactory', () => {
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
userInfo: mockUserInfoDatabaseHandler,
});
const { token } = await factory.issueToken({
@@ -25,7 +25,6 @@ import {
} from '@backstage/plugin-auth-node';
import { AnyJWK, KeyStore, TokenIssuer } from './types';
import { JsonValue } from '@backstage/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { issueUserToken } from './issueUserToken';
/**
@@ -92,7 +91,6 @@ type Options = {
* A list of claims to omit from issued tokens and only store in the user info database
*/
omitClaimsFromToken?: string[];
userInfo: UserInfoDatabase;
};
/**
@@ -116,7 +114,6 @@ export class TokenFactory implements TokenIssuer {
private readonly keyDurationSeconds: number;
private readonly algorithm: string;
private readonly omitClaimsFromToken?: string[];
private readonly userInfo: UserInfoDatabase;
private keyExpiry?: Date;
private privateKeyPromise?: Promise<JWK>;
@@ -128,10 +125,11 @@ export class TokenFactory implements TokenIssuer {
this.keyDurationSeconds = options.keyDurationSeconds;
this.algorithm = options.algorithm ?? 'ES256';
this.omitClaimsFromToken = options.omitClaimsFromToken;
this.userInfo = options.userInfo;
}
async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
async issueToken(
params: TokenParams & { claims: { ent: string[] } },
): Promise<BackstageSignInResult> {
const key = await this.getKey();
return issueUserToken({
@@ -141,7 +139,6 @@ export class TokenFactory implements TokenIssuer {
logger: this.logger,
omitClaimsFromToken: this.omitClaimsFromToken,
params,
userInfo: this.userInfo,
});
}
@@ -22,7 +22,6 @@ import {
tokenTypes,
} from '@backstage/plugin-auth-node';
import { omit } from 'lodash';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { LoggerService } from '@backstage/backend-plugin-api';
import { GeneralSign, importJWK, JWK, KeyLike, SignJWT } from 'jose';
import { BackstageTokenPayload } from './TokenFactory';
@@ -37,17 +36,15 @@ export async function issueUserToken({
logger,
omitClaimsFromToken,
params,
userInfo,
}: {
issuer: string;
key: JWK;
keyDurationSeconds: number;
logger: LoggerService;
omitClaimsFromToken?: string[];
params: TokenParams;
userInfo: UserInfoDatabase;
params: TokenParams & { claims: { ent: string[] } };
}): Promise<BackstageSignInResult> {
const { sub, ent = [sub], ...additionalClaims } = params.claims;
const { sub, ent, ...additionalClaims } = params.claims;
const aud = tokenTypes.user.audClaim;
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + keyDurationSeconds;
@@ -109,12 +106,6 @@ export async function issueUserToken({
);
}
// Store the user info in the database upon successful token
// issuance so that it can be retrieved later by limited user tokens
await userInfo.addUserInfo({
claims: omit(claims, ['aud', 'iat', 'iss', 'uip']),
});
return {
token,
identity: {
@@ -19,6 +19,7 @@ import { mockServices } from '@backstage/backend-test-utils';
import { TokenIssuer } from '../../identity/types';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { NotFoundError } from '@backstage/errors';
import { UserInfoDatabase } from '../../database/UserInfoDatabase';
describe('CatalogAuthResolverContext', () => {
beforeEach(() => {
@@ -28,6 +29,16 @@ describe('CatalogAuthResolverContext', () => {
const catalog = catalogServiceMock();
jest.spyOn(catalog, 'getEntities');
const mockUserInfo = {
addUserInfo: jest.fn().mockResolvedValue(undefined),
getUserInfo: jest.fn().mockResolvedValue({
claims: {
sub: 'user:default/user',
ent: ['user:default/user'],
},
}),
} as unknown as jest.Mocked<UserInfoDatabase>;
it('adds kind to filter when missing', async () => {
const auth = mockServices.auth();
const context = CatalogAuthResolverContext.create({
@@ -35,6 +46,7 @@ describe('CatalogAuthResolverContext', () => {
catalog,
tokenIssuer: {} as TokenIssuer,
auth,
userInfo: mockUserInfo,
});
await expect(
@@ -32,6 +32,7 @@ import {
TokenParams,
} from '@backstage/plugin-auth-node';
import { CatalogIdentityClient } from '../catalog/CatalogIdentityClient';
import { UserInfoDatabase } from '../../database/UserInfoDatabase';
function getDefaultOwnershipEntityRefs(entity: Entity) {
const membershipRefs =
@@ -51,6 +52,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
tokenIssuer: TokenIssuer;
auth: AuthService;
ownershipResolver?: AuthOwnershipResolver;
userInfo: UserInfoDatabase;
}): CatalogAuthResolverContext {
const catalogIdentityClient = new CatalogIdentityClient({
catalog: options.catalog,
@@ -63,6 +65,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
catalogIdentityClient,
options.catalog,
options.auth,
options.userInfo,
options.ownershipResolver,
);
}
@@ -73,11 +76,29 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
public readonly catalogIdentityClient: CatalogIdentityClient,
private readonly catalog: CatalogService,
private readonly auth: AuthService,
private readonly userInfo: UserInfoDatabase,
private readonly ownershipResolver?: AuthOwnershipResolver,
) {}
async issueToken(params: TokenParams) {
return await this.tokenIssuer.issueToken(params);
// todo(blam): ideally, we would update the token issuer to require the ent claim
// instead of doing the destructuring in two places. But that would be a breaking change.
const { sub, ent = [sub], ...additionalClaims } = params.claims;
const claims = {
sub,
ent,
...additionalClaims,
};
// Store the user info in the database upon successful token
// issuance so that it can be retrieved later by limited user tokens
await this.userInfo.addUserInfo({
claims,
});
return await this.tokenIssuer.issueToken({
claims,
});
}
async findCatalogUser(query: AuthResolverCatalogUserQuery) {
@@ -160,7 +181,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
entity,
);
return await this.tokenIssuer.issueToken({
return await this.issueToken({
claims: {
sub: stringifyEntityRef(entity),
ent: ownershipEntityRefs,
@@ -180,7 +201,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
}),
);
return await this.tokenIssuer.issueToken({
return await this.issueToken({
claims: {
sub: userEntityRef,
ent: [userEntityRef],
@@ -107,4 +107,37 @@ describe('migrations', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'20250707164600_user_created_at.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20250707164600_user_created_at.js');
const user_info = JSON.stringify({
claims: {
ent: ['group:default/group1', 'group:default/group2'],
},
});
await knex
.insert({
user_entity_ref: 'user:default/backstage-user',
user_info,
exp: knex.fn.now(),
})
.into('user_info');
const { exp } = await knex('user_info').first();
await migrateUpOnce(knex);
const { created_at, updated_at } = await knex('user_info').first();
expect(updated_at).toBe(exp);
expect(created_at).toBeDefined();
},
);
});
@@ -27,6 +27,7 @@ import Router from 'express-promise-router';
import { Minimatch } from 'minimatch';
import { CatalogAuthResolverContext } from '../lib/resolvers/CatalogAuthResolverContext';
import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
export type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -40,6 +41,7 @@ export function bindProviderRouters(
logger: LoggerService;
auth: AuthService;
tokenIssuer: TokenIssuer;
userInfo: UserInfoDatabase;
ownershipResolver?: AuthOwnershipResolver;
catalog: CatalogService;
},
@@ -54,6 +56,7 @@ export function bindProviderRouters(
tokenIssuer,
catalog,
ownershipResolver,
userInfo,
} = options;
const providersConfig = config.getOptionalConfig('auth.providers');
@@ -82,6 +85,7 @@ export function bindProviderRouters(
tokenIssuer,
auth,
ownershipResolver,
userInfo,
}),
});
+1 -2
View File
@@ -94,7 +94,6 @@ export async function createRouter(
logger: logger.child({ component: 'token-factory' }),
issuer: authUrl,
sessionExpirationSeconds: backstageTokenExpiration,
userInfo,
omitClaimsFromToken,
},
keyStore as StaticKeyStore,
@@ -108,7 +107,6 @@ export async function createRouter(
algorithm:
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
userInfo,
omitClaimsFromToken,
});
}
@@ -146,6 +144,7 @@ export async function createRouter(
tokenIssuer,
...options,
auth: options.auth,
userInfo,
});
const oidcRouter = OidcRouter.create({