Skip to content

Commit 2e454bb

Browse files
authored
Merge pull request #11 from datum-cloud/feat/proxy-auth-from-gateway
2 parents e0211de + 051a397 commit 2e454bb

10 files changed

Lines changed: 303 additions & 2 deletions

File tree

app-config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ app:
55
# Enable all packages by default, this will discover packages from packages/app/package.json
66
packages: all
77

8+
# When set, the sign-in page proxies to this auth provider (the gateway
9+
# session) instead of showing guest sign-in. Left unset for local dev / e2e
10+
# so the guest sign-in page is used. See auth.providers.datumGateway below.
11+
# signInPage:
12+
# provider: datumGateway
13+
814
extensions:
915
# Configure the catalog index page to be the root page, this is normally mounted on /catalog
1016
- page:catalog:
@@ -85,6 +91,19 @@ auth:
8591
providers:
8692
# See https://backstage.io/docs/auth/guest/provider
8793
guest: {}
94+
# Trust the identity forwarded by the internal-auth gateway. Left unset for
95+
# local dev (guest is used); enable per-environment by providing the block
96+
# below. The provider validates the raw OIDC ID token JWT the gateway
97+
# forwards in `header` (signature via the issuer JWKS, plus
98+
# issuer/audience/expiry) and resolves the email to a catalog User.
99+
# datumGateway:
100+
# header: X-Auth-Request-Id-Token
101+
# issuer: https://accounts.google.com
102+
# audience: ${BACKSTAGE_GATEWAY_AUDIENCE}
103+
# jwksUrl: https://www.googleapis.com/oauth2/v3/certs
104+
# signIn:
105+
# resolvers:
106+
# - resolver: emailMatchingUserEntityProfileEmail
88107
# see https://backstage.io/docs/ai/mcp-actions#client-id-metadata-documents
89108
# to learn more about client id metadata documents
90109
experimentalClientIdMetadataDocuments:

packages/app/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
"backstage": {
77
"role": "frontend"
88
},
9+
"configSchema": {
10+
"type": "object",
11+
"properties": {
12+
"app": {
13+
"type": "object",
14+
"properties": {
15+
"signInPage": {
16+
"type": "object",
17+
"properties": {
18+
"provider": { "type": "string", "visibility": "frontend" }
19+
}
20+
}
21+
}
22+
}
23+
}
24+
},
925
"scripts": {
1026
"start": "backstage-cli package start",
1127
"build": "backstage-cli package build",

packages/app/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createApp } from '@backstage/frontend-defaults';
22
import catalogPlugin from '@backstage/plugin-catalog/alpha';
3+
import { authModule } from './modules/auth';
34
import { navModule } from './modules/nav';
45
import { themeModule } from './modules/theme';
56

67
export default createApp({
7-
features: [catalogPlugin, navModule, themeModule],
8+
features: [catalogPlugin, authModule, navModule, themeModule],
89
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { configApiRef, useApi } from '@backstage/frontend-plugin-api';
2+
import { ProxiedSignInPage, SignInPage } from '@backstage/core-components';
3+
import type { SignInPageProps } from '@backstage/plugin-app-react';
4+
5+
export function SignInPageSwitch(props: SignInPageProps) {
6+
const config = useApi(configApiRef);
7+
const provider = config.getOptionalString('app.signInPage.provider');
8+
9+
if (provider) {
10+
return <ProxiedSignInPage {...props} provider={provider} />;
11+
}
12+
13+
return <SignInPage {...props} providers={['guest']} />;
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createFrontendModule } from '@backstage/frontend-plugin-api';
2+
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
3+
4+
const signInPage = SignInPageBlueprint.make({
5+
params: {
6+
loader: async () => (await import('./SignInPage')).SignInPageSwitch,
7+
},
8+
});
9+
10+
export const authModule = createFrontendModule({
11+
pluginId: 'app',
12+
extensions: [signInPage],
13+
});

packages/backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
},
1818
"dependencies": {
1919
"@backstage/backend-defaults": "^0.17.3",
20+
"@backstage/backend-plugin-api": "^1.9.2",
2021
"@backstage/config": "^1.3.8",
22+
"@backstage/errors": "^1.3.1",
2123
"@backstage/plugin-app-backend": "^0.5.15",
2224
"@backstage/plugin-auth-backend": "^0.29.1",
2325
"@backstage/plugin-auth-backend-module-github-provider": "^0.5.4",
@@ -46,6 +48,7 @@
4648
"@backstage/plugin-techdocs-backend": "^2.2.1",
4749
"app": "link:../app",
4850
"better-sqlite3": "^12.0.0",
51+
"jose": "^5.10.0",
4952
"node-gyp": "^10.0.0",
5053
"pg": "^8.11.3"
5154
},

packages/backend/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ backend.add(import('@backstage/plugin-auth-backend'));
2828
// See https://backstage.io/docs/backend-system/building-backends/migrating#the-auth-plugin
2929
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
3030
// See https://backstage.io/docs/auth/guest/provider
31+
backend.add(import('./modules/authModuleDatumGatewayProvider'));
3132

3233
// catalog plugin
3334
backend.add(import('@backstage/plugin-catalog-backend'));
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { ConfigReader } from '@backstage/config';
2+
import type { Request } from 'express';
3+
import * as jose from 'jose';
4+
import { datumGatewayAuthenticator } from './authModuleDatumGatewayProvider';
5+
6+
jest.mock('jose', () => {
7+
const actual = jest.requireActual('jose');
8+
return { __esModule: true, ...actual, createRemoteJWKSet: jest.fn() };
9+
});
10+
11+
const ISSUER = 'https://accounts.google.com';
12+
const AUDIENCE = 'test-client-id.apps.googleusercontent.com';
13+
const HEADER = 'x-auth-request-id-token';
14+
const KID = 'test-key';
15+
16+
let privateKey: jose.KeyLike;
17+
18+
const reqWith = (token?: string): Request =>
19+
({
20+
header: (name: string) =>
21+
name.toLowerCase() === HEADER ? token : undefined,
22+
} as unknown as Request);
23+
24+
const sign = (
25+
claims: jose.JWTPayload,
26+
opts: { issuer?: string; audience?: string; expSeconds?: number } = {},
27+
) =>
28+
new jose.SignJWT(claims)
29+
.setProtectedHeader({ alg: 'RS256', kid: KID })
30+
.setIssuer(opts.issuer ?? ISSUER)
31+
.setAudience(opts.audience ?? AUDIENCE)
32+
.setIssuedAt()
33+
.setExpirationTime(`${opts.expSeconds ?? 3600}s`)
34+
.sign(privateKey);
35+
36+
const initialize = () =>
37+
datumGatewayAuthenticator.initialize({
38+
config: new ConfigReader({
39+
header: 'X-Auth-Request-Id-Token',
40+
issuer: ISSUER,
41+
audience: AUDIENCE,
42+
jwksUrl: 'https://www.googleapis.com/oauth2/v3/certs',
43+
}),
44+
});
45+
46+
beforeAll(async () => {
47+
const pair = await jose.generateKeyPair('RS256');
48+
privateKey = pair.privateKey;
49+
const publicJwk = await jose.exportJWK(pair.publicKey);
50+
const localJwks = jest
51+
.requireActual('jose')
52+
.createLocalJWKSet({ keys: [{ ...publicJwk, kid: KID, alg: 'RS256' }] });
53+
(jose.createRemoteJWKSet as jest.Mock).mockReturnValue(localJwks);
54+
});
55+
56+
describe('datumGatewayAuthenticator', () => {
57+
it('accepts a valid Google ID token and exposes its claims', async () => {
58+
const ctx = initialize();
59+
const token = await sign({ email: 'alice@datum.net', name: 'Alice' });
60+
61+
const { result } = await datumGatewayAuthenticator.authenticate(
62+
{ req: reqWith(token) },
63+
ctx,
64+
);
65+
66+
expect(result.claims.email).toBe('alice@datum.net');
67+
expect(result.claims.aud).toBe(AUDIENCE);
68+
});
69+
70+
it('maps claims to a profile with email and display name', async () => {
71+
const ctx = initialize();
72+
const token = await sign({ email: 'alice@datum.net', name: 'Alice' });
73+
const { result } = await datumGatewayAuthenticator.authenticate(
74+
{ req: reqWith(token) },
75+
ctx,
76+
);
77+
78+
const { profile } = await datumGatewayAuthenticator.defaultProfileTransform(
79+
result,
80+
{} as any,
81+
);
82+
83+
expect(profile).toEqual({
84+
email: 'alice@datum.net',
85+
displayName: 'Alice',
86+
picture: undefined,
87+
});
88+
});
89+
90+
it('rejects when the header is missing', async () => {
91+
const ctx = initialize();
92+
await expect(
93+
datumGatewayAuthenticator.authenticate({ req: reqWith() }, ctx),
94+
).rejects.toThrow(/Missing/);
95+
});
96+
97+
it('rejects a token with the wrong audience', async () => {
98+
const ctx = initialize();
99+
const token = await sign(
100+
{ email: 'alice@datum.net' },
101+
{ audience: 'someone-else' },
102+
);
103+
await expect(
104+
datumGatewayAuthenticator.authenticate({ req: reqWith(token) }, ctx),
105+
).rejects.toThrow(/verification failed/);
106+
});
107+
108+
it('rejects a token with the wrong issuer', async () => {
109+
const ctx = initialize();
110+
const token = await sign(
111+
{ email: 'alice@datum.net' },
112+
{ issuer: 'https://evil.example.com' },
113+
);
114+
await expect(
115+
datumGatewayAuthenticator.authenticate({ req: reqWith(token) }, ctx),
116+
).rejects.toThrow(/verification failed/);
117+
});
118+
119+
it('rejects an expired token', async () => {
120+
const ctx = initialize();
121+
const token = await sign({ email: 'alice@datum.net' }, { expSeconds: -60 });
122+
await expect(
123+
datumGatewayAuthenticator.authenticate({ req: reqWith(token) }, ctx),
124+
).rejects.toThrow(/verification failed/);
125+
});
126+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { createBackendModule } from '@backstage/backend-plugin-api';
2+
import {
3+
authProvidersExtensionPoint,
4+
commonSignInResolvers,
5+
createProxyAuthenticator,
6+
createProxyAuthProviderFactory,
7+
} from '@backstage/plugin-auth-node';
8+
import { AuthenticationError } from '@backstage/errors';
9+
import {
10+
createRemoteJWKSet,
11+
jwtVerify,
12+
type JWTPayload,
13+
type JWTVerifyGetKey,
14+
} from 'jose';
15+
16+
const DEFAULT_HEADER = 'x-auth-request-id-token';
17+
const DEFAULT_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
18+
19+
type DatumGatewayResult = { claims: JWTPayload };
20+
21+
type DatumGatewayContext = {
22+
header: string;
23+
jwks: JWTVerifyGetKey;
24+
issuer: string[];
25+
audience: string;
26+
};
27+
28+
const bearer = /^Bearer[ ]+(\S+)$/i;
29+
30+
export const datumGatewayAuthenticator = createProxyAuthenticator({
31+
defaultProfileTransform: async (result: DatumGatewayResult) => ({
32+
profile: {
33+
email:
34+
typeof result.claims.email === 'string'
35+
? result.claims.email
36+
: undefined,
37+
displayName:
38+
typeof result.claims.name === 'string' ? result.claims.name : undefined,
39+
picture:
40+
typeof result.claims.picture === 'string'
41+
? result.claims.picture
42+
: undefined,
43+
},
44+
}),
45+
initialize({ config }): DatumGatewayContext {
46+
const header = (
47+
config.getOptionalString('header') ?? DEFAULT_HEADER
48+
).toLowerCase();
49+
const issuer = config.getString('issuer');
50+
const audience = config.getString('audience');
51+
const jwksUrl = config.getOptionalString('jwksUrl') ?? DEFAULT_JWKS_URL;
52+
53+
return {
54+
header,
55+
audience,
56+
jwks: createRemoteJWKSet(new URL(jwksUrl)),
57+
issuer: issuer.startsWith('https://accounts.google.com')
58+
? [issuer, 'accounts.google.com']
59+
: [issuer],
60+
};
61+
},
62+
async authenticate({ req }, ctx) {
63+
const raw = req.header(ctx.header);
64+
if (!raw) {
65+
throw new AuthenticationError(`Missing ${ctx.header} header`);
66+
}
67+
const token = bearer.exec(raw)?.[1] ?? raw.trim();
68+
69+
try {
70+
const { payload } = await jwtVerify(token, ctx.jwks, {
71+
issuer: ctx.issuer,
72+
audience: ctx.audience,
73+
});
74+
return { result: { claims: payload } };
75+
} catch (error) {
76+
throw new AuthenticationError(
77+
'Gateway ID token verification failed',
78+
error,
79+
);
80+
}
81+
},
82+
});
83+
84+
export const authModuleDatumGatewayProvider = createBackendModule({
85+
pluginId: 'auth',
86+
moduleId: 'datum-gateway-provider',
87+
register(reg) {
88+
reg.registerInit({
89+
deps: { providers: authProvidersExtensionPoint },
90+
async init({ providers }) {
91+
providers.registerProvider({
92+
providerId: 'datumGateway',
93+
factory: createProxyAuthProviderFactory({
94+
authenticator: datumGatewayAuthenticator,
95+
signInResolverFactories: {
96+
...commonSignInResolvers,
97+
},
98+
}),
99+
});
100+
},
101+
});
102+
},
103+
});
104+
105+
export default authModuleDatumGatewayProvider;

yarn.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13071,8 +13071,10 @@ __metadata:
1307113071
resolution: "backend@workspace:packages/backend"
1307213072
dependencies:
1307313073
"@backstage/backend-defaults": "npm:^0.17.3"
13074+
"@backstage/backend-plugin-api": "npm:^1.9.2"
1307413075
"@backstage/cli": "npm:^0.36.3"
1307513076
"@backstage/config": "npm:^1.3.8"
13077+
"@backstage/errors": "npm:^1.3.1"
1307613078
"@backstage/plugin-app-backend": "npm:^0.5.15"
1307713079
"@backstage/plugin-auth-backend": "npm:^0.29.1"
1307813080
"@backstage/plugin-auth-backend-module-github-provider": "npm:^0.5.4"
@@ -13101,6 +13103,7 @@ __metadata:
1310113103
"@backstage/plugin-techdocs-backend": "npm:^2.2.1"
1310213104
app: "link:../app"
1310313105
better-sqlite3: "npm:^12.0.0"
13106+
jose: "npm:^5.10.0"
1310413107
node-gyp: "npm:^10.0.0"
1310513108
pg: "npm:^8.11.3"
1310613109
languageName: unknown
@@ -20882,7 +20885,7 @@ __metadata:
2088220885
languageName: node
2088320886
linkType: hard
2088420887

20885-
"jose@npm:^5.0.0":
20888+
"jose@npm:^5.0.0, jose@npm:^5.10.0":
2088620889
version: 5.10.0
2088720890
resolution: "jose@npm:5.10.0"
2088820891
checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0

0 commit comments

Comments
 (0)