Skip to content

Commit

Permalink
Merge branch 'jul/oidc-pre-verification' into 'master'
Browse files Browse the repository at this point in the history
feat(oidc): add preverified oidc verification method

See merge request TankerHQ/sdk-js!1012
  • Loading branch information
JMounier committed Mar 4, 2024
2 parents 550406e + 4d2a51f commit 37f0ea4
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 52 deletions.
14 changes: 13 additions & 1 deletion packages/core/src/LocalUser/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ type PhoneNumberRequest = {
type E2ePassphraseRequest = {
hashed_e2e_passphrase: Uint8Array;
};
type OIDCRequest = {
oidc_subject: string,
oidc_provider_id: string,
};

export type PreverifiedVerificationRequest = Preverified<EmailRequest> | Preverified<PhoneNumberRequest>;
export type PreverifiedVerificationRequest = Preverified<EmailRequest> | Preverified<PhoneNumberRequest> | Preverified<OIDCRequest>;

export type VerificationRequestWithToken = WithToken<PassphraseRequest>
| WithVerificationCode<EmailRequest>
Expand Down Expand Up @@ -109,6 +113,14 @@ export const formatVerificationRequest = async (
};
}

if ('preverifiedOIDCSubject' in verification) {
return {
oidc_provider_id: verification.oidcProviderID,
oidc_subject: verification.preverifiedOIDCSubject,
is_preverified: true,
};
}

if ('preverifiedEmail' in verification) {
return {
hashed_email: generichash(utils.fromString(verification.preverifiedEmail)),
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/LocalUser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export type OidcVerification = { oidcIdToken: string; };
export type PhoneNumberVerification = { phoneNumber: string; verificationCode: string; };
export type PreverifiedEmailVerification = { preverifiedEmail: string; };
export type PreverifiedPhoneNumberVerification = { preverifiedPhoneNumber: string; };
export type PreverifiedVerification = PreverifiedEmailVerification | PreverifiedPhoneNumberVerification;
export type PreverifiedOIDCVerification = { preverifiedOIDCSubject: string; oidcProviderID: string };
export type PreverifiedVerification = PreverifiedEmailVerification | PreverifiedPhoneNumberVerification | PreverifiedOIDCVerification;

export type ProvisionalVerification = EmailVerification | PhoneNumberVerification;
export type E2eRemoteVerification = E2ePassphraseVerification;
Expand All @@ -32,7 +33,8 @@ export type RemoteVerification = E2eRemoteVerification
| OidcVerification
| PhoneNumberVerification
| PreverifiedEmailVerification
| PreverifiedPhoneNumberVerification;
| PreverifiedPhoneNumberVerification
| PreverifiedOIDCVerification;
export type Verification = RemoteVerification | KeyVerification;

export type WithTokenOptions = { withToken?: { nonce: string; }; };
Expand All @@ -42,17 +44,17 @@ export type RemoteVerificationWithToken = RemoteVerification & WithTokenOptions;
export type VerificationOptions = { withSessionToken?: boolean; allowE2eMethodSwitch?: boolean; };

const validE2eMethods = ['e2ePassphrase'];
const validNonE2eMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken', 'phoneNumber', 'preverifiedEmail', 'preverifiedPhoneNumber'];
const validNonE2eMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken', 'phoneNumber', 'preverifiedEmail', 'preverifiedPhoneNumber', 'preverifiedOIDCSubject'];
const validMethods = [...validE2eMethods, ...validNonE2eMethods];
const validKeys = [...validMethods, 'verificationCode'];
const validKeys = [...validMethods, 'verificationCode', 'oidcProviderID'];

const validVerifOptionsKeys = ['withSessionToken', 'allowE2eMethodSwitch'];

export const isE2eVerification = (verification: VerificationWithToken): verification is E2eRemoteVerification => Object.keys(verification).some(k => validE2eMethods.includes(k));

export const isNonE2eVerification = (verification: VerificationWithToken) => Object.keys(verification).some(k => validNonE2eMethods.includes(k));

export const isPreverifiedVerification = (verification: VerificationWithToken): verification is PreverifiedVerification => 'preverifiedEmail' in verification || 'preverifiedPhoneNumber' in verification;
export const isPreverifiedVerification = (verification: VerificationWithToken): verification is PreverifiedVerification => 'preverifiedEmail' in verification || 'preverifiedPhoneNumber' in verification || 'preverifiedOIDCSubject' in verification;

export const isPreverifiedVerificationMethod = (verificationMethod: VerificationMethod): verificationMethod is (PreverifiedEmailVerificationMethod | PreverifiedPhoneNumberVerificationMethod) => verificationMethod.type === 'preverifiedEmail' || verificationMethod.type === 'preverifiedPhoneNumber';

Expand Down Expand Up @@ -95,6 +97,12 @@ export const assertVerification = (verification: Verification) => {
assertNotEmptyString(verification.preverifiedEmail, 'verification.preverifiedEmail');
} else if ('preverifiedPhoneNumber' in verification) {
assertNotEmptyString(verification.preverifiedPhoneNumber, 'verification.preverifiedPhoneNumber');
} else if ('preverifiedOIDCSubject' in verification) {
assertNotEmptyString(verification.preverifiedOIDCSubject, 'verification.preverifiedOIDCSubject');
if (!('oidcProviderID' in verification)) {
throw new InvalidArgument('verification', 'oidc pre-verification should also have a oidcProviderID', verification);
}
assertNotEmptyString(verification.oidcProviderID, 'verification.oidcProviderID');
}
};

Expand Down Expand Up @@ -135,12 +143,15 @@ export const countPreverifiedVerifications = (verifications: Array<PreverifiedVe
const counts = {
preverifiedEmail: 0,
preverifiedPhoneNumber: 0,
preverifiedOIDCSubject: 0,
};
verifications.forEach((verification) => {
if ('preverifiedEmail' in verification) {
counts.preverifiedEmail += 1;
} else if ('preverifiedPhoneNumber' in verification) {
counts.preverifiedPhoneNumber += 1;
} else if ('preverifiedOIDCSubject' in verification) {
counts.preverifiedOIDCSubject += 1;
} else {
assertNever(verification, 'verification');
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Network/ErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const apiCodeErrorMap: Record<string, Class<TankerError>> = {
verification_code_not_found: InvalidVerification,
verification_key_not_found: PreconditionFailed,
verification_method_not_set: PreconditionFailed,
oidc_provider_not_configured: PreconditionFailed,
};

export const genericErrorHandler = (apiMethod: string, apiRoute: string, error: Record<string, any>) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/Tanker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export class Tanker extends EventEmitter {
}

const counts = countPreverifiedVerifications(verifications);
if (counts.preverifiedEmail > 1 || counts.preverifiedPhoneNumber > 1) {
if (counts.preverifiedEmail > 1 || counts.preverifiedPhoneNumber > 1 || counts.preverifiedOIDCSubject > 1) {
throw new InvalidArgument('verications', 'contains at most one of each preverified verification method', counts);
}

Expand Down Expand Up @@ -311,7 +311,7 @@ export class Tanker extends EventEmitter {
verifWithToken.withToken = { nonce: randomBase64Token() };
}

if ('preverifiedEmail' in verification || 'preverifiedPhoneNumber' in verification) {
if (isPreverifiedVerification(verification)) {
throw new InvalidArgument('verification', 'cannot register identity with preverified methods');
}

Expand Down Expand Up @@ -339,7 +339,7 @@ export class Tanker extends EventEmitter {
assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity');
}

if ('preverifiedEmail' in verification || 'preverifiedPhoneNumber' in verification) {
if (isPreverifiedVerification(verification)) {
throw new InvalidArgument('verification', 'cannot verify identity with preverified methods');
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type {
KeyVerification,
PreverifiedEmailVerification,
PreverifiedPhoneNumberVerification,
PreverifiedOIDCVerification,
PreverifiedVerification,
Verification,
VerificationMethod,
Expand Down
41 changes: 37 additions & 4 deletions packages/functional-tests/src/enroll.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Tanker, b64string, PreverifiedPhoneNumberVerification, PreverifiedEmailVerification } from '@tanker/core';
import type { Tanker, b64string, PreverifiedPhoneNumberVerification, PreverifiedEmailVerification, PreverifiedOIDCVerification } from '@tanker/core';
import { expect } from '@tanker/test-utils';
import { getPublicIdentity } from '@tanker/identity';
import { statuses, errors } from '@tanker/core';
import { expectDecrypt } from './helpers';
import { expectDecrypt, oidcSettings } from './helpers';

import type { TestArgs, AppHelper } from './helpers';
import { extractSubject, getGoogleIdToken } from './helpers';

export const generateEnrollTests = (args: TestArgs) => {
describe('Enrolling users', () => {
Expand All @@ -16,17 +17,26 @@ export const generateEnrollTests = (args: TestArgs) => {
let bobIdentity: b64string;
let emailVerification: PreverifiedEmailVerification;
let phoneNumberVerification: PreverifiedPhoneNumberVerification;
let oidcVerification: PreverifiedOIDCVerification;
let providerID: string;

before(() => {
before(async () => {
server = args.makeTanker();
({ appHelper } = args);

const config = await appHelper.setOidc();
providerID = config.app.oidc_providers[0]!.id;

emailVerification = {
preverifiedEmail: email,
};
phoneNumberVerification = {
preverifiedPhoneNumber: phoneNumber,
};
oidcVerification = {
oidcProviderID: providerID,
preverifiedOIDCSubject: 'a subject',
};
});

beforeEach(async () => {
Expand All @@ -43,6 +53,10 @@ export const generateEnrollTests = (args: TestArgs) => {
await expect(server.enrollUser(bobIdentity, [phoneNumberVerification])).to.be.rejectedWith(errors.PreconditionFailed);
});

it('fails to enroll a user with oidc', async () => {
return expect(server.enrollUser(bobIdentity, [oidcVerification])).to.be.rejectedWith(errors.PreconditionFailed);
});

it('fails to enroll a user with both an email address and a phone number [ARRQBH]', async () => {
await expect(server.enrollUser(bobIdentity, [emailVerification, phoneNumberVerification])).to.be.rejectedWith(errors.PreconditionFailed);
});
Expand All @@ -61,6 +75,10 @@ export const generateEnrollTests = (args: TestArgs) => {
await expect(server.enrollUser(bobIdentity, [phoneNumberVerification])).to.be.fulfilled;
});

it('enrolls a user with an oidc', async () => {
await expect(server.enrollUser(bobIdentity, [oidcVerification])).to.be.fulfilled;
});

it('throws when enrolling a user multiple times [BMANVI]', async () => {
await expect(server.enrollUser(bobIdentity, [emailVerification])).to.be.fulfilled;
await expect(server.enrollUser(bobIdentity, [phoneNumberVerification])).to.be.rejectedWith(errors.Conflict);
Expand Down Expand Up @@ -88,17 +106,26 @@ export const generateEnrollTests = (args: TestArgs) => {
describe('enrolled user', () => {
let bobLaptop: Tanker;
let bobPhone: Tanker;
let bobTablet: Tanker;
let bobPubIdentity: b64string;
let bobIdToken: string;
const clearText = 'new enrollment feature';

before(async () => {
await appHelper.setEnrollUsersEnabled();
// Let's say Martine is bob's middle name
bobIdToken = await getGoogleIdToken(oidcSettings.googleAuth.users.martine.refreshToken);
oidcVerification.preverifiedOIDCSubject = extractSubject(bobIdToken);
});

after(async () => {
await appHelper.unsetOidc();
});

beforeEach(async () => {
bobLaptop = args.makeTanker();
bobPubIdentity = await getPublicIdentity(bobIdentity);
await server.enrollUser(bobIdentity, [emailVerification, phoneNumberVerification]);
await server.enrollUser(bobIdentity, [emailVerification, phoneNumberVerification, oidcVerification]);

const disposableIdentity = await appHelper.generateIdentity();
await server.start(disposableIdentity);
Expand All @@ -112,17 +139,23 @@ export const generateEnrollTests = (args: TestArgs) => {

it('must verify new devices [FJQEQC]', async () => {
bobPhone = args.makeTanker();
bobTablet = args.makeTanker();

await bobLaptop.start(bobIdentity);
await bobPhone.start(bobIdentity);
await bobTablet.start(bobIdentity);

const emailCode = await appHelper.getEmailVerificationCode(email);
const phoneNumberCode = await appHelper.getSMSVerificationCode(phoneNumber);
await bobTablet.setOidcTestNonce(await bobTablet.createOidcNonce());

expect(bobLaptop.status).to.eq(statuses.IDENTITY_VERIFICATION_NEEDED);
expect(bobPhone.status).to.eq(statuses.IDENTITY_VERIFICATION_NEEDED);
expect(bobTablet.status).to.eq(statuses.IDENTITY_VERIFICATION_NEEDED);

await expect(bobLaptop.verifyIdentity({ email, verificationCode: emailCode })).to.be.fulfilled;
await expect(bobPhone.verifyIdentity({ phoneNumber, verificationCode: phoneNumberCode })).to.be.fulfilled;
await expect(bobTablet.verifyIdentity({ oidcIdToken: bobIdToken })).to.be.fulfilled;
});

it('can attache a provisional identity [BV4VOS]', async () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/functional-tests/src/helpers/AppHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export class AppHelper {
return new AppHelper(makeTanker, appId, appSecret);
}

async _update(body: Record<string, unknown>): Promise<void> {
await requestManagement({
async _update(body: Record<string, unknown>): Promise<unknown> {
return requestManagement({
method: 'PATCH',
path: `/v2/apps/${utils.toRawUrlBase64(this.appId)}`,
body,
Expand All @@ -66,14 +66,14 @@ export class AppHelper {
'pro-sante-bas-no-expiry': 'https://auth.bas.psc.esante.gouv.fr/auth/realms/esante-wallet',
};

await this._update({
return this._update({
oidc_providers: [{
display_name: provider,
issuer: providersIssuer[provider],
client_id: providers[provider],
ignore_token_expiration: provider === 'pro-sante-bas-no-expiry',
}],
});
}) as Promise<{ app: { oidc_providers: Array<{ id: string, display_name: string }> } }>;
}

async unsetOidc() {
Expand Down
1 change: 1 addition & 0 deletions packages/functional-tests/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export type { EncryptedBuffer } from './encrypt';
export { encrypt, checkDecrypt, checkDecryptFails } from './encrypt';
export { checkGroup } from './groups';
export { ignoreTag } from './tag';
export { extractSubject, getGoogleIdToken } from './oidc';
36 changes: 36 additions & 0 deletions packages/functional-tests/src/helpers/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { oidcSettings } from './config';

import { utils } from '@tanker/crypto';

export async function getGoogleIdToken(refreshToken: string): Promise<string> {
const formData = JSON.stringify({
client_id: oidcSettings.googleAuth.clientId,
client_secret: oidcSettings.googleAuth.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});

const url = 'https://www.googleapis.com/oauth2/v4/token';

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: formData,
});

if (!response.ok) {
const description = `${response.status} ${response.statusText}: ${await response.text()}`;
throw new Error(`Failed to get an ID token from ${url}:\n${description}`);
}

const data = await response.json();
return data.id_token;
}

export function extractSubject(jwt: string): string {
const b64body = jwt.split('.')[1]!;
const body = utils.toString(utils.fromSafeBase64(b64body));
return JSON.parse(body).sub;
}
2 changes: 1 addition & 1 deletion packages/functional-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function generateFunctionalTests(

args.makeTanker = (b64AppId = b64DefaultAppId, extraOpts = {}) => makeTanker(b64AppId, makePrefix(), extraOpts);

silencer.silence('warn', /deprecated/);
silencer.silence('warn', /(deprecated)|('testNonce' field)/);
});

after(async () => {
Expand Down
Loading

0 comments on commit 37f0ea4

Please sign in to comment.