Skip to content

Commit

Permalink
Merge branch 'main' into org-pub-key
Browse files Browse the repository at this point in the history
  • Loading branch information
gnarea authored Sep 22, 2023
2 parents b21669c + 2ed6cc3 commit e20e410
Show file tree
Hide file tree
Showing 17 changed files with 93 additions and 41 deletions.
2 changes: 1 addition & 1 deletion docs/api-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This server exposes a RESTful API to manage VeraId organisations and the endpoin

## Authentication and authorisation

We use OAuth2 with JWKS to delegate authentication to an external identity provider. We require the JWT token's `sub` claim to be the email address of the user.
We use OAuth2 with JWKS to delegate authentication to an external identity provider. We require the JWT token to define the user's email address in the [OIDC `email` claim](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims).

The API employs the following roles:

Expand Down
2 changes: 1 addition & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The Docker container must use the image above and specify the following argument
- Authentication-related variables:
- `OAUTH2_JWKS_URL` (required). The URL to the JWKS endpoint of the authorisation server.
- Either `OAUTH2_TOKEN_ISSUER` or `OAUTH2_TOKEN_ISSUER_REGEX` (required). The (URL of the) authorisation server.
- `OAUTH2_TOKEN_AUDIENCE` (required). The identifier of the current instance of this server (typically its public URL).
- `OAUTH2_TOKEN_AUDIENCE` (required). The comma-separated identifier(s) of the current instance of this server (typically its public URL).
- Authorisation-related variables:
- `AUTHORITY_SUPERADMIN` (optional): The JWT _subject id_ of the super admin, which in this app we require it to be an email address. When unset, routes that require super admin role (e.g., `POST /orgs`) won't work by design. This is desirable in cases where an instance of this server will only ever support a handful of domain names (they could set the `AUTHORITY_SUPERADMIN` to create the orgs, and then unset the super admin var).

Expand Down
23 changes: 23 additions & 0 deletions k8s/mock-authz-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ spec:
containers:
- name: mock-oauth2-server
image: ghcr.io/navikt/mock-oauth2-server:0.5.8
env:
- name: JSON_CONFIG
value: |
{
"tokenCallbacks": [
{
"issuerId": "default",
"tokenExpiry": 120,
"requestMappings": [
{
"requestParam": "scope",
"match": "super-admin",
"claims": {"email": "[email protected]"}
},
{
"requestParam": "scope",
"match": "user",
"claims": {"email": "[email protected]"}
}
]
}
]
}
readinessProbe:
httpGet:
path: /default/.well-known/openid-configuration
8 changes: 4 additions & 4 deletions src/api/orgAuthPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface OrgRequestParams {
}

interface AuthenticatedFastifyRequest extends FastifyRequest {
user: { sub: string };
user: { email: string };
}

interface AuthorisedFastifyRequest extends AuthenticatedFastifyRequest {
Expand Down Expand Up @@ -76,7 +76,7 @@ function registerOrgAuth(fastify: FastifyInstance, _opts: PluginMetadata, done:

fastify.addHook('onRequest', async (request, reply) => {
const superAdmin = envVar.get('AUTHORITY_SUPERADMIN').asString();
const userEmail = (request as AuthenticatedFastifyRequest).user.sub;
const userEmail = (request as AuthenticatedFastifyRequest).user.email;
const decision = await decideAuthorisation(userEmail, request, fastify.mongoose, superAdmin);
const reason = decision.didSucceed ? decision.result.reason : decision.context;
if (decision.didSucceed) {
Expand All @@ -102,11 +102,11 @@ const requireUserToBeAdmin: any = async (
reply: FastifyReply,
) => {
if (!request.isUserAdmin) {
await denyAuthorisation('User is not an admin', reply, request.user.sub);
await denyAuthorisation('User is not an admin', reply, request.user.email);
}
};

const orgAuthPlugin = fastifyPlugin(registerOrgAuth, { name: 'org-auth' });
export default orgAuthPlugin;

export { requireUserToBeAdmin };
export { type AuthenticatedFastifyRequest, requireUserToBeAdmin };
15 changes: 8 additions & 7 deletions src/functionalTests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { MEMBER_EMAIL, TEST_SERVICE_OID } from '../testUtils/stubs.js';
import { generateKeyPair } from '../testUtils/webcrypto.js';
import { derSerialisePublicKey } from '../utilities/webcrypto.js';

import { API_URL, makeClient, SUPER_ADMIN_EMAIL } from './utils/api.js';
import { API_URL, makeClient } from './utils/api.js';
import { post } from './utils/http.js';
import { AuthScope } from './utils/authServer.js';

function generateOrgName(): string {
return `${randomUUID()}.example`;
Expand All @@ -42,14 +43,14 @@ describe('API', () => {
});

test('Create org as super admin', async () => {
const client = await makeClient(SUPER_ADMIN_EMAIL);
const client = await makeClient(AuthScope.SUPER_ADMIN);
const command = new OrgCreationCommand({ name: generateOrgName() });

await expect(client.send(command)).toResolve();
});

test('Create org admin as super admin', async () => {
const client = await makeClient(SUPER_ADMIN_EMAIL);
const client = await makeClient(AuthScope.SUPER_ADMIN);
const { members: membersEndpoint } = await client.send(
new OrgCreationCommand({ name: generateOrgName() }),
);
Expand All @@ -62,7 +63,7 @@ describe('API', () => {
});

test('Create member as org admin', async () => {
const superAdminClient = await makeClient(SUPER_ADMIN_EMAIL);
const superAdminClient = await makeClient(AuthScope.SUPER_ADMIN);
const { members: membersEndpoint } = await superAdminClient.send(
new OrgCreationCommand({ name: generateOrgName() }),
);
Expand All @@ -74,7 +75,7 @@ describe('API', () => {
}),
);

const orgAdminClient = await makeClient(MEMBER_EMAIL);
const orgAdminClient = await makeClient(AuthScope.USER);
const memberCreationCommand = new MemberCreationCommand({
endpoint: membersEndpoint,
role: MemberRole.REGULAR,
Expand All @@ -83,7 +84,7 @@ describe('API', () => {
});

test('Import public key as regular org member', async () => {
const superAdminClient = await makeClient(SUPER_ADMIN_EMAIL);
const superAdminClient = await makeClient(AuthScope.SUPER_ADMIN);
const { members: membersEndpoint } = await superAdminClient.send(
new OrgCreationCommand({ name: generateOrgName() }),
);
Expand All @@ -95,7 +96,7 @@ describe('API', () => {
}),
);

const memberClient = await makeClient(MEMBER_EMAIL);
const memberClient = await makeClient(AuthScope.USER);
const { publicKey } = await generateKeyPair();
const keyImportCommand = new MemberPublicKeyImportCommand({
endpoint: publicKeysEndpoint,
Expand Down
5 changes: 3 additions & 2 deletions src/functionalTests/awala.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ import { CE_ID } from '../testUtils/eventing/stubs.js';
import { INCOMING_SERVICE_MESSAGE_TYPE } from '../events/incomingServiceMessage.event.js';

import { connectToClusterService } from './utils/kubernetes.js';
import { makeClient, SUPER_ADMIN_EMAIL } from './utils/api.js';
import { makeClient } from './utils/api.js';
import { ORG_PRIVATE_KEY_ARN, ORG_PUBLIC_KEY_DER, TEST_ORG_NAME } from './utils/veraid.js';
import { getServiceUrl } from './utils/knative.js';
import { postEvent } from './utils/events.js';
import { AuthScope } from './utils/authServer.js';

const CLIENT = await makeClient(SUPER_ADMIN_EMAIL);
const CLIENT = await makeClient(AuthScope.SUPER_ADMIN);

const AWALA_SERVER_URL = await getServiceUrl('veraid-authority-awala');

Expand Down
7 changes: 3 additions & 4 deletions src/functionalTests/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { AuthorityClient } from '@relaycorp/veraid-authority';

import { getServiceUrl } from './knative.js';
import { authenticate } from './authServer.js';
import { authenticate, type AuthScope } from './authServer.js';

export const SUPER_ADMIN_EMAIL = '[email protected]';
export const API_URL = await getServiceUrl('veraid-authority');

export async function makeClient(userEmail: string): Promise<AuthorityClient> {
const authHeader = await authenticate(userEmail);
export async function makeClient(scope: AuthScope): Promise<AuthorityClient> {
const authHeader = await authenticate(scope);
return new AuthorityClient(API_URL, authHeader);
}
10 changes: 8 additions & 2 deletions src/functionalTests/utils/authServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@ import { post } from './http.js';
const AUTH_SERVER_URL = await getServiceUrl('mock-authz-server');
const AUTH_ENDPOINT_URL = `${AUTH_SERVER_URL}/default/token`;

export async function authenticate(clientId: string): Promise<AuthorizationHeader> {
export enum AuthScope {
SUPER_ADMIN = 'super-admin',
USER = 'user',
}

export async function authenticate(scope: AuthScope): Promise<AuthorizationHeader> {
const body = {
// eslint-disable-next-line @typescript-eslint/naming-convention,camelcase
grant_type: 'client_credentials',
// eslint-disable-next-line @typescript-eslint/naming-convention,camelcase
client_id: clientId,
client_id: 'client',
// eslint-disable-next-line @typescript-eslint/naming-convention,camelcase
client_secret: 's3cr3t',
scope,
};
const response = await post(AUTH_ENDPOINT_URL, {
headers: new Headers([['Content-Type', 'application/x-www-form-urlencoded']]),
Expand Down
3 changes: 2 additions & 1 deletion src/models/Member.model.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { index, prop, Severity } from '@typegoose/typegoose';
import { index, modelOptions, prop, Severity } from '@typegoose/typegoose';

export enum Role {
ORG_ADMIN = 'org_admin',
REGULAR = 'regular',
}

@modelOptions({ schemaOptions: { collection: 'members' } })
@index(
{ orgName: 1, name: 1 },
{ unique: true, partialFilterExpression: { name: { $type: 'string' } } },
Expand Down
3 changes: 2 additions & 1 deletion src/models/MemberBundleRequest.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { prop } from '@typegoose/typegoose';
import { modelOptions, prop } from '@typegoose/typegoose';

@modelOptions({ schemaOptions: { collection: 'member_bundle_requests' } })
export class MemberBundleRequestModelSchema {
@prop({ required: true, unique: true })
public publicKeyId!: string;
Expand Down
3 changes: 2 additions & 1 deletion src/models/MemberKeyImportToken.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { prop } from '@typegoose/typegoose';
import { modelOptions, prop } from '@typegoose/typegoose';

@modelOptions({ schemaOptions: { collection: 'member_key_import_tokens' } })
export class MemberKeyImportTokenModelSchema {
@prop({ required: true })
public memberId!: string;
Expand Down
2 changes: 2 additions & 0 deletions src/models/MemberPublicKey.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { prop, modelOptions } from '@typegoose/typegoose';

@modelOptions({
schemaOptions: {
collection: 'member_public_keys',

timestamps: {
createdAt: 'creationDate',
updatedAt: false,
Expand Down
3 changes: 2 additions & 1 deletion src/models/Org.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { prop } from '@typegoose/typegoose';
import { modelOptions, prop } from '@typegoose/typegoose';

@modelOptions({ schemaOptions: { collection: 'orgs' } })
export class OrgModelSchema {
@prop({ required: true, unique: true, index: true })
public name!: string;
Expand Down
3 changes: 2 additions & 1 deletion src/testUtils/apiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { PluginDone } from '../utilities/fastify/PluginDone.js';
import { HTTP_STATUS_CODES } from '../utilities/http.js';
import { MemberModelSchema, Role } from '../models/Member.model.js';
import type { Result, SuccessfulResult } from '../utilities/result.js';
import type { AuthenticatedFastifyRequest } from '../api/orgAuthPlugin.js';

import { makeTestServer, type TestServerFixture } from './server.js';
import { OAUTH2_JWKS_URL, OAUTH2_TOKEN_AUDIENCE, OAUTH2_TOKEN_ISSUER } from './authn.js';
Expand Down Expand Up @@ -83,7 +84,7 @@ function getMockAuthenticateFromServer(fastify: FastifyInstance) {
function setAuthUser(fastify: FastifyInstance, userEmail: string) {
// eslint-disable-next-line @typescript-eslint/require-await
getMockAuthenticateFromServer(fastify).mockImplementation(async (request) => {
(request as unknown as { user: { sub: string } }).user = { sub: userEmail };
(request as AuthenticatedFastifyRequest).user = { email: userEmail };
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/testUtils/authn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export const OAUTH2_JWKS_URL = 'https://authn.idp.example/.well-known/jwks.json'

export const OAUTH2_TOKEN_ISSUER = 'https://idp.example/';

export const OAUTH2_TOKEN_AUDIENCE = 'the audience';
export const OAUTH2_TOKEN_AUDIENCE = 'https://audience.com';
41 changes: 28 additions & 13 deletions src/utilities/fastify/plugins/jwksAuthentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,36 @@ describe('jwks-authentication', () => {
);
});

test('OAUTH2_TOKEN_AUDIENCE should be defined', async () => {
mockEnvVars({ ...AUTHN_ENV_VARS, OAUTH2_TOKEN_AUDIENCE: undefined });
describe('OAUTH2_TOKEN_AUDIENCE', () => {
test('should be defined', async () => {
mockEnvVars({ ...AUTHN_ENV_VARS, OAUTH2_TOKEN_AUDIENCE: undefined });

await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage(
envVar.EnvVarError,
/OAUTH2_TOKEN_AUDIENCE/u,
);
});

await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage(
envVar.EnvVarError,
/OAUTH2_TOKEN_AUDIENCE/u,
);
});
test('should be used as the audience', async () => {
await jwksPlugin(mockFastify, {});

test('OAUTH2_TOKEN_AUDIENCE should be used as the audience', async () => {
await jwksPlugin(mockFastify, {});
const [audience] = OAUTH2_TOKEN_AUDIENCE.split(',');
expect(mockFastify.register).toHaveBeenCalledWith(
fastifyJwtJwks,
expect.objectContaining({ audience: [audience] }),
);
});

expect(mockFastify.register).toHaveBeenCalledWith(
fastifyJwtJwks,
expect.objectContaining({ audience: OAUTH2_TOKEN_AUDIENCE }),
);
test('should be support multiple values', async () => {
const audiences = ['audience1', 'audience2'];
mockEnvVars({ ...AUTHN_ENV_VARS, OAUTH2_TOKEN_AUDIENCE: audiences.join(',') });

await jwksPlugin(mockFastify, {});

expect(mockFastify.register).toHaveBeenCalledWith(
fastifyJwtJwks,
expect.objectContaining({ audience: audiences }),
);
});
});
});
2 changes: 1 addition & 1 deletion src/utilities/fastify/plugins/jwksAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fastifyJwtJwks, { type FastifyJwtJwksOptions } from 'fastify-jwt-jwks';
import env from 'env-var';

function getOauth2PluginOptions(): FastifyJwtJwksOptions {
const audience = env.get('OAUTH2_TOKEN_AUDIENCE').required().asString();
const audience = env.get('OAUTH2_TOKEN_AUDIENCE').required().asArray(',');
const jwksUrl = env.get('OAUTH2_JWKS_URL').required().asUrlString();

const issuer = env.get('OAUTH2_TOKEN_ISSUER').asString();
Expand Down

0 comments on commit e20e410

Please sign in to comment.