pr feedback: make service tokens configurable

Signed-off-by: Tyler Davis <tylerd@canva.com>
This commit is contained in:
Tyler Davis
2024-02-07 20:26:04 +11:00
parent 293c835e05
commit 3191d616e8
3 changed files with 62 additions and 2 deletions
+2
View File
@@ -25,6 +25,8 @@ auth:
providers:
cfaccess:
teamName: <Team Name>
serviceTokens:
"1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com
```
You can find the team name in the Cloudflare Zero Trust dashboard.
@@ -43,6 +43,15 @@ const mockServiceTokenClaims = {
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockServiceTokenDisallowedJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIiLCJuYW1lIjoiQm90IiwiY29tbW9uX25hbWUiOiJzb21lX290aGVyX3Rva2VuX2lkLmFjY2VzcyIsImlhdCI6MTUxNjIzOTAyMn0.qQeeQW_urYrrTq-tuKZWURwTUrjzgyFyZA9ViQtD-FM';
const mockServiceTokenDisallowedClaims = {
sub: '',
common_name: 'some_other_token_id.access',
iat: 1632833760,
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockCfIdentity = {
name: 'foo',
id: '123',
@@ -162,6 +171,12 @@ describe('CloudflareAccessAuthProvider', () => {
}),
} as unknown as express.Request;
const mockRequestWithSericeTokenDisallowedJwtHeader = {
header: jest.fn(() => {
return mockServiceTokenDisallowedJwt;
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(_ => {
return undefined;
@@ -179,6 +194,7 @@ describe('CloudflareAccessAuthProvider', () => {
const provider = new CloudflareAccessAuthProvider({
teamName: 'foobar',
serviceTokens: {},
resolverContext: {} as AuthResolverContext,
authHandler: async result => {
expect(result).toEqual(
@@ -212,6 +228,9 @@ describe('CloudflareAccessAuthProvider', () => {
const providerServiceToken = new CloudflareAccessAuthProvider({
teamName: 'foobar',
serviceTokens: {
'test_token_id.access': 'test_token_id.access@foobar.com',
},
resolverContext: {} as AuthResolverContext,
authHandler: async result => {
expect(result).toEqual(
@@ -267,6 +286,18 @@ describe('CloudflareAccessAuthProvider', () => {
);
});
it('rejects a disallowed service token JWT without calling get-identity', async () => {
jwtMock.mockReturnValue(
Promise.resolve({ payload: mockServiceTokenDisallowedClaims }),
);
await expect(
providerServiceToken.refresh(
mockRequestWithSericeTokenDisallowedJwtHeader,
mockResponse,
),
).rejects.toThrow();
});
it('returns cfidentity also when get-identity succeeds', async () => {
jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims }));
mockFetch.mockReturnValueOnce(
@@ -48,6 +48,8 @@ const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1';
*/
export const CF_DEFAULT_CACHE_TTL = 3600;
type ServiceTokens = Record<string, string>;
/** @public */
export type Options = {
/**
@@ -58,6 +60,15 @@ export type Options = {
* https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/certs
*/
teamName: string;
/**
* Allowed Cloudflare Service Tokens
*
* Cloudflare does not currently allow assigning any sort of identity to
* Service Tokens. Therefore, this allows you to build an allow list mapping
* the Client ID of any Service Tokens that should be allowed to pass the
* auth check to the identity (email) you would like to associate with it.
*/
serviceTokens: ServiceTokens;
authHandler: AuthHandler<CloudflareAccessResult>;
signInResolver: SignInResolver<CloudflareAccessResult>;
resolverContext: AuthResolverContext;
@@ -178,6 +189,7 @@ export type CloudflareAccessResponse =
export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
private readonly teamName: string;
private readonly serviceTokens: ServiceTokens;
private readonly resolverContext: AuthResolverContext;
private readonly authHandler: AuthHandler<CloudflareAccessResult>;
private readonly signInResolver: SignInResolver<CloudflareAccessResult>;
@@ -186,6 +198,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
constructor(options: Options) {
this.teamName = options.teamName;
this.serviceTokens = options.serviceTokens;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.resolverContext = options.resolverContext;
@@ -260,7 +273,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
issuer: `https://${this.teamName}.cloudflareaccess.com`,
});
const isServiceToken = verifyResult.payload.sub === '';
const isServiceToken = !verifyResult.payload.sub;
const subject = isServiceToken
? (verifyResult.payload.common_name as string)
@@ -271,6 +284,12 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
);
}
if (isServiceToken && !this.serviceTokens.hasOwnProperty(subject)) {
throw new AuthenticationError(
`${subject} is not a permitted Service Token.`,
);
}
const cacheKey = `${CACHE_PREFIX}/${subject}`;
const cfAccessResultStr = await this.cache?.get(cacheKey);
if (typeof cfAccessResultStr === 'string') {
@@ -289,7 +308,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
cfIdentity = {
id: subject,
name: 'Bot',
email: `${subject}@${this.teamName}.com`,
email: this.serviceTokens[subject],
groups: [],
};
} else {
@@ -372,6 +391,13 @@ export const cfAccess = createAuthProviderIntegration({
}) {
return ({ config, resolverContext }) => {
const teamName = config.getString('teamName');
const serviceTokensConfig = config.getOptionalConfig('serviceTokens');
const serviceTokens: ServiceTokens = {};
if (serviceTokensConfig) {
serviceTokensConfig.keys().forEach(key => {
serviceTokens[key] = serviceTokensConfig.getString(key);
});
}
if (!options.signIn.resolver) {
throw new Error(
@@ -393,6 +419,7 @@ export const cfAccess = createAuthProviderIntegration({
return new CloudflareAccessAuthProvider({
teamName,
serviceTokens,
signInResolver: options?.signIn.resolver,
authHandler,
resolverContext,