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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user