feat(cli): add auth commands for OIDC login (#32920)

* feat(cli): add auth commands for OIDC login

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback

- move CIMD check before callback server start
- add try/finally for callback server cleanup
- validate URLs with human-readable errors
- deduplicate config URL candidates
- preserve selected flag on re-authentication
- delete accessToken on logout
- log token refresh to stderr in show command
- fix command descriptions to reference CIMD not DCR
- type keytar as optionalDependency, rename storage paths
- add auth-backend changeset

Signed-off-by: benjdlambert <ben@blam.sh>

* migrate auth module from yargs to cleye pattern

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback

- consolidate storage imports in auth.ts
- add withMetadataLock to setSelectedInstance
- skip file permission tests on Windows
- clarify changeset endpoint path

Signed-off-by: benjdlambert <ben@blam.sh>

* address review feedback from Rugvip and Copilot

- use stdout for user-facing messages instead of stderr
- remove clientSecret remnants from logout
- make refresh_token optional in token response schema
- add timeout to CIMD metadata fetch
- pass same state to callback server and authorize URL
- remove inaccurate test comment

Signed-off-by: benjdlambert <ben@blam.sh>

* validate state in callback server, add CIMD endpoint tests

- localServer now validates the OAuth state parameter in the request
  handler and returns 400 on mismatch
- Added tests for the CIMD metadata endpoint in OidcRouter covering
  both disabled and enabled cases

Signed-off-by: benjdlambert <ben@blam.sh>

* revert validateRequest to use Zod error details

Signed-off-by: benjdlambert <ben@blam.sh>

* fix callback server hanging by closing keep-alive connections

Signed-off-by: benjdlambert <ben@blam.sh>

* rename secret store service prefix to backstage-cli:auth-instance

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-10 14:28:25 +01:00
committed by GitHub
parent d056002e64
commit d0f4cd215b
29 changed files with 3314 additions and 1 deletions
@@ -1120,6 +1120,124 @@ describe('OidcRouter', () => {
});
});
describe('CIMD metadata endpoint', () => {
it('should return 404 when CIMD is not enabled', async () => {
const { router } = await createRouter(databaseId);
const { server } = await startTestBackend({
features: [
createBackendPlugin({
pluginId: 'auth',
register(reg) {
reg.registerInit({
deps: { httpRouter: coreServices.httpRouter },
async init({ httpRouter }) {
httpRouter.use(router.getRouter());
httpRouter.addAuthPolicy({
path: '/',
allow: 'unauthenticated',
});
},
});
},
}),
],
});
const response = await request(server)
.get('/api/auth/.well-known/oauth-client/cli.json')
.expect(404);
expect(response.body).toEqual({
error: 'not_found',
error_description: 'Client ID metadata documents not enabled',
});
});
it('should return CIMD document when enabled', async () => {
const knex = await databases.init(databaseId);
await knex.migrate.latest({
directory: resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
),
});
const authDatabase = AuthDatabase.create({
getClient: async () => knex,
});
const oidcDatabase = await OidcDatabase.create({
database: authDatabase,
});
const userInfoDatabase = await UserInfoDatabase.create({
database: authDatabase,
});
const mockTokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
} as unknown as jest.Mocked<TokenIssuer>;
const oidcRouter = OidcRouter.create({
auth: mockServices.auth.mock(),
tokenIssuer: mockTokenIssuer,
baseUrl: 'http://localhost:7007/api/auth',
appUrl: 'http://localhost:3000',
logger: mockServices.logger.mock(),
userInfo: userInfoDatabase,
oidc: oidcDatabase,
httpAuth: mockServices.httpAuth.mock(),
config: mockServices.rootConfig({
data: {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
},
},
},
}),
});
const { server } = await startTestBackend({
features: [
createBackendPlugin({
pluginId: 'auth',
register(reg) {
reg.registerInit({
deps: { httpRouter: coreServices.httpRouter },
async init({ httpRouter }) {
httpRouter.use(oidcRouter.getRouter());
httpRouter.addAuthPolicy({
path: '/',
allow: 'unauthenticated',
});
},
});
},
}),
],
});
const response = await request(server)
.get('/api/auth/.well-known/oauth-client/cli.json')
.expect(200);
expect(response.body).toEqual({
client_id:
'http://localhost:7007/api/auth/.well-known/oauth-client/cli.json',
client_name: 'Backstage CLI',
redirect_uris: ['http://127.0.0.1:8055/callback'],
response_types: ['code'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
scope: 'openid offline_access',
});
});
});
describe('CIMD authorization', () => {
it('should enable authorization routes when only CIMD is enabled (not DCR)', async () => {
const cimdClientId = 'https://example.com/oauth-client';
@@ -145,6 +145,7 @@ export class OidcRouter {
private readonly appUrl: string;
private readonly httpAuth: HttpAuthService;
private readonly config: RootConfigService;
private readonly baseUrl: string;
private constructor(
oidc: OidcService,
@@ -153,6 +154,7 @@ export class OidcRouter {
appUrl: string,
httpAuth: HttpAuthService,
config: RootConfigService,
baseUrl: string,
) {
this.oidc = oidc;
this.logger = logger;
@@ -160,6 +162,7 @@ export class OidcRouter {
this.appUrl = appUrl;
this.httpAuth = httpAuth;
this.config = config;
this.baseUrl = baseUrl;
}
static create(options: {
@@ -181,6 +184,7 @@ export class OidcRouter {
options.appUrl,
options.httpAuth,
options.config,
options.baseUrl,
);
}
@@ -204,6 +208,32 @@ export class OidcRouter {
res.json({ keys });
});
// CIMD metadata endpoint for the Backstage CLI
// Automatically available when CIMD is enabled
router.get('/.well-known/oauth-client/cli.json', (_req, res) => {
const cimdEnabled = this.config.getOptionalBoolean(
'auth.experimentalClientIdMetadataDocuments.enabled',
);
if (!cimdEnabled) {
res.status(404).json({
error: 'not_found',
error_description: 'Client ID metadata documents not enabled',
});
return;
}
res.json({
client_id: `${this.baseUrl}/.well-known/oauth-client/cli.json`,
client_name: 'Backstage CLI',
redirect_uris: ['http://127.0.0.1:8055/callback'],
response_types: ['code'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
scope: 'openid offline_access',
});
});
// UserInfo endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
// Returns claims about the authenticated user using an access token