Merge pull request #33385 from drodil/auth_mem_leaks

fix(auth): memory leaks
This commit is contained in:
Patrik Oldsberg
2026-03-17 11:03:20 +01:00
committed by GitHub
7 changed files with 170 additions and 37 deletions
@@ -0,0 +1,49 @@
/*
* Copyright 2026 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
/**
* Drop the foreign key constraint on offline_sessions.oidc_client_id
* to allow CIMD (Client ID Metadata Document) clients which are not stored
* in the oidc_clients table.
*
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('offline_sessions', table => {
table.dropForeign(['oidc_client_id']);
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
// Delete sessions with CIMD client_ids (not in oidc_clients) before re-adding FK
await knex('offline_sessions')
.whereNotNull('oidc_client_id')
.whereNotIn('oidc_client_id', knex('oidc_clients').select('client_id'))
.delete();
await knex.schema.alterTable('offline_sessions', table => {
table
.foreign('oidc_client_id')
.references('client_id')
.inTable('oidc_clients')
.onDelete('CASCADE');
});
};
@@ -109,6 +109,7 @@ describe('OidcRouter', () => {
userInfo: userInfoDatabase,
oidc: oidcDatabase,
config: mockConfig,
logger: mockServices.logger.mock(),
});
const oidcRouter = OidcRouter.create({
@@ -194,6 +195,7 @@ describe('OidcRouter', () => {
userInfo: userInfoDatabase,
oidc: oidcDatabase,
config: mockConfig,
logger: mockServices.logger.mock(),
offlineAccess,
});
@@ -20,6 +20,7 @@ import {
} from '@backstage/backend-test-utils';
import { JsonObject } from '@backstage/types';
import { OidcService } from './OidcService';
import { OfflineAccessService } from './OfflineAccessService';
import {
BackstageCredentials,
BackstageServicePrincipal,
@@ -53,10 +54,11 @@ describe('OidcService', () => {
interface CreateOidcServiceOptions {
databaseId: TestDatabaseId;
config?: JsonObject;
offlineAccess?: OfflineAccessService;
}
async function createOidcService(options: CreateOidcServiceOptions) {
const { databaseId, config: configData = {} } = options;
const { databaseId, config: configData = {}, offlineAccess } = options;
const knex = await databases.init(databaseId);
@@ -94,6 +96,8 @@ describe('OidcService', () => {
userInfo: mockUserInfo,
oidc: oidcDatabase,
config,
logger: mockServices.logger.mock(),
offlineAccess,
}),
mocks: {
auth: mockAuth,
@@ -836,6 +840,50 @@ describe('OidcService', () => {
}),
).rejects.toThrow('Invalid code verifier');
});
it('should still return access token when refresh token issuance fails', async () => {
const mockOfflineAccess = {
issueRefreshToken: jest
.fn()
.mockRejectedValue(new Error('DB constraint violation')),
} as unknown as OfflineAccessService;
const { service, mocks } = await createOidcService({
databaseId,
offlineAccess: mockOfflineAccess,
});
const mockToken = 'mock-jwt-token';
mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken });
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
responseType: 'code',
scope: 'openid offline_access',
});
const authResult = await service.approveAuthorizationSession({
sessionId: authSession.id,
userEntityRef: 'user:default/test',
});
const code = new URL(authResult.redirectUrl).searchParams.get('code')!;
const tokenResult = await service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
grantType: 'authorization_code',
});
expect(tokenResult.accessToken).toBe(mockToken);
expect(tokenResult.refreshToken).toBeUndefined();
expect(mockOfflineAccess.issueRefreshToken).toHaveBeenCalledTimes(1);
});
});
describe('CIMD (Client ID Metadata Document) support', () => {
@@ -13,7 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthService, RootConfigService } from '@backstage/backend-plugin-api';
import {
AuthService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import {
@@ -48,6 +52,7 @@ export class OidcService {
private readonly userInfo: UserInfoDatabase;
private readonly oidc: OidcDatabase;
private readonly config: RootConfigService;
private readonly logger: LoggerService;
private readonly offlineAccess?: OfflineAccessService;
private constructor(
@@ -57,6 +62,7 @@ export class OidcService {
userInfo: UserInfoDatabase,
oidc: OidcDatabase,
config: RootConfigService,
logger: LoggerService,
offlineAccess?: OfflineAccessService,
) {
this.auth = auth;
@@ -65,6 +71,7 @@ export class OidcService {
this.userInfo = userInfo;
this.oidc = oidc;
this.config = config;
this.logger = logger;
this.offlineAccess = offlineAccess;
}
@@ -75,6 +82,7 @@ export class OidcService {
userInfo: UserInfoDatabase;
oidc: OidcDatabase;
config: RootConfigService;
logger: LoggerService;
offlineAccess?: OfflineAccessService;
}) {
return new OidcService(
@@ -84,6 +92,7 @@ export class OidcService {
options.userInfo,
options.oidc,
options.config,
options.logger,
options.offlineAccess,
);
}
@@ -509,10 +518,18 @@ export class OidcService {
let refreshToken: string | undefined;
const scopes = session.scope?.split(' ') ?? [];
if (scopes.includes('offline_access') && this.offlineAccess) {
refreshToken = await this.offlineAccess.issueRefreshToken({
userEntityRef: session.userEntityRef,
oidcClientId: session.clientId,
});
try {
refreshToken = await this.offlineAccess.issueRefreshToken({
userEntityRef: session.userEntityRef,
oidcClientId: session.clientId,
});
} catch (err) {
// Don't fail the entire token exchange if refresh token issuance fails.
// The access token is still valid and should be returned.
this.logger.warn(
`Failed to issue refresh token for user ${session.userEntityRef}, offline_access will not be available: ${err}`,
);
}
}
return {