Skip to content

Commit

Permalink
Merge branch 'jul/oidc-idp' into 'master'
Browse files Browse the repository at this point in the history
Add authorization code verification method

See merge request TankerHQ/sdk-js!1011
  • Loading branch information
JMounier committed Apr 16, 2024
2 parents 78b6bce + eca256d commit 89ff21f
Show file tree
Hide file tree
Showing 16 changed files with 182 additions and 15 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSER_STACK_ACCESS_KEY }}
BROWSER_STACK_USERNAME: ${{ secrets.BROWSER_STACK_USERNAME }}
TANKER_APPD_URL: ${{ secrets.TANKER_APPD_URL }}
TANKER_FAKE_OIDC_URL: ${{ secrets.TANKER_FAKE_OIDC_URL }}
TANKER_FILEKIT_BUCKET_NAME: ${{ secrets.TANKER_FILEKIT_BUCKET_NAME }}
TANKER_FILEKIT_BUCKET_REGION: ${{ secrets.TANKER_FILEKIT_BUCKET_REGION }}
TANKER_FILEKIT_CLIENT_ID: ${{ secrets.TANKER_FILEKIT_CLIENT_ID }}
Expand Down Expand Up @@ -70,6 +71,7 @@ jobs:
BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSER_STACK_ACCESS_KEY }}
BROWSER_STACK_USERNAME: ${{ secrets.BROWSER_STACK_USERNAME }}
TANKER_APPD_URL: ${{ secrets.TANKER_APPD_URL }}
TANKER_FAKE_OIDC_URL: ${{ secrets.TANKER_FAKE_OIDC_URL }}
TANKER_FILEKIT_BUCKET_NAME: ${{ secrets.TANKER_FILEKIT_BUCKET_NAME }}
TANKER_FILEKIT_BUCKET_REGION: ${{ secrets.TANKER_FILEKIT_BUCKET_REGION }}
TANKER_FILEKIT_CLIENT_ID: ${{ secrets.TANKER_FILEKIT_CLIENT_ID }}
Expand Down
2 changes: 2 additions & 0 deletions config/karma/tanker.test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const plugin = new webpack.EnvironmentPlugin({
TANKER_OIDC_MARTINE_EMAIL: null,
TANKER_OIDC_MARTINE_REFRESH_TOKEN: null,

TANKER_FAKE_OIDC_URL: null,

TANKER_FILEKIT_BUCKET_NAME: null,
TANKER_FILEKIT_BUCKET_REGION: null,
TANKER_FILEKIT_CLIENT_ID: null,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"Firefox ESR",
"node >= 18",
"not IE 11",
"not op_mini all",
"not dead"
],
"nyc": {
Expand Down
4 changes: 4 additions & 0 deletions packages/client-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class Tanker extends TankerCore {
constructor(options: TankerOptions) {
super(optionsWithDefaults(options, defaultOptions));
}

// authenticateWithIdP() is only exposed in client-browser because it relies on Cookies
// and Cookies are not handled by node fetch
authenticateWithIdP = this._authenticateWithIdP;
}

export { errors, fromBase64, toBase64, prehashPassword, Padding } from '@tanker/core';
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/LocalUser/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
PreverifiedVerification,
RemoteVerificationWithToken,
LegacyEmailVerificationMethod,
OidcAuthorizationCodeVerification,
} from './types';
import { isE2eVerification } from './types';
import { generateUserCreation, generateDeviceFromGhostDevice, generateGhostDevice } from './UserCreation';
Expand Down Expand Up @@ -230,7 +231,7 @@ export class LocalUserManager extends EventEmitter {
first_device_creation: firstDeviceBlock,
};

if ('email' in verification || 'passphrase' in verification || 'oidcIdToken' in verification || 'phoneNumber' in verification) {
if ('email' in verification || 'passphrase' in verification || 'oidcIdToken' in verification || 'oidcAuthorizationCode' in verification || 'phoneNumber' in verification) {
request.v2_encrypted_verification_key = ghostDeviceToEncryptedVerificationKey(ghostDevice, this._localUser.userSecret);
request.verification = await formatVerificationRequest(verification, this);
request.verification.with_token = verification.withToken; // May be undefined
Expand Down Expand Up @@ -388,4 +389,6 @@ export class LocalUserManager extends EventEmitter {
await oidcNonceManage.removeOidcNonce(nonce);
return res;
};

createOidcAuthorizationCode = async (oidcProviderId: string): Promise<OidcAuthorizationCodeVerification> => this._client.oidcSignIn(oidcProviderId);
}
14 changes: 14 additions & 0 deletions packages/core/src/LocalUser/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ type OidcIdTokenRequest = {
oidc_challenge_signature: b64string;
oidc_test_nonce: string | undefined;
};
type OidcAuthorizationCode = {
oidc_provider_id: string;
oidc_authorization_code: string;
oidc_state: string;
};
type PhoneNumberRequest = {
phone_number: string;
encrypted_phone_number: Uint8Array;
Expand All @@ -41,6 +46,7 @@ export type PreverifiedVerificationRequest = Preverified<EmailRequest> | Preveri

export type VerificationRequestWithToken = WithToken<PassphraseRequest>
| WithVerificationCode<EmailRequest>
| WithToken<OidcAuthorizationCode>
| WithToken<OidcIdTokenRequest>
| WithVerificationCode<PhoneNumberRequest>
| WithToken<E2ePassphraseRequest>;
Expand Down Expand Up @@ -121,6 +127,14 @@ export const formatVerificationRequest = async (
};
}

if ('oidcAuthorizationCode' in verification) {
return {
oidc_authorization_code: verification.oidcAuthorizationCode,
oidc_provider_id: verification.oidcProviderId,
oidc_state: verification.oidcState,
};
}

if ('preverifiedEmail' in verification) {
return {
hashed_email: generichash(utils.fromString(verification.preverifiedEmail)),
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/LocalUser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type EmailVerification = { email: string; verificationCode: string; };
export type PassphraseVerification = { passphrase: string; };
export type E2ePassphraseVerification = { e2ePassphrase: string; };
export type KeyVerification = { verificationKey: string; };
export type OidcAuthorizationCodeVerification = { oidcProviderId: string; oidcAuthorizationCode: string; oidcState: string; };
export type OidcVerification = { oidcIdToken: string; };
export type PhoneNumberVerification = { phoneNumber: string; verificationCode: string; };
export type PreverifiedEmailVerification = { preverifiedEmail: string; };
Expand All @@ -30,6 +31,7 @@ export type E2eRemoteVerification = E2ePassphraseVerification;
export type RemoteVerification = E2eRemoteVerification
| EmailVerification
| PassphraseVerification
| OidcAuthorizationCodeVerification
| OidcVerification
| PhoneNumberVerification
| PreverifiedEmailVerification
Expand All @@ -44,9 +46,9 @@ export type RemoteVerificationWithToken = RemoteVerification & WithTokenOptions;
export type VerificationOptions = { withSessionToken?: boolean; allowE2eMethodSwitch?: boolean; };

const validE2eMethods = ['e2ePassphrase'];
const validNonE2eMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken', 'phoneNumber', 'preverifiedEmail', 'preverifiedPhoneNumber', 'preverifiedOidcSubject'];
const validNonE2eMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken', 'oidcAuthorizationCode', 'phoneNumber', 'preverifiedEmail', 'preverifiedPhoneNumber', 'preverifiedOidcSubject'];
const validMethods = [...validE2eMethods, ...validNonE2eMethods];
const validKeys = [...validMethods, 'verificationCode', 'oidcProviderId'];
const validKeys = [...validMethods, 'verificationCode', 'oidcProviderId', 'oidcState'];

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

Expand Down Expand Up @@ -93,6 +95,10 @@ export const assertVerification = (verification: Verification) => {
if ('testNonce' in verification) {
console.warn("'testNonce' field should be used for tests purposes only. It will be rejected for non-test Tanker application");
}
} else if ('oidcAuthorizationCode' in verification) {
assertNotEmptyString(verification.oidcProviderId, 'verification.oidcProviderId');
assertNotEmptyString(verification.oidcAuthorizationCode, 'verification.oidcAuthorizationCode');
assertNotEmptyString(verification.oidcState, 'verification.oidcState');
} else if ('preverifiedEmail' in verification) {
assertNotEmptyString(verification.preverifiedEmail, 'verification.preverifiedEmail');
} else if ('preverifiedPhoneNumber' in verification) {
Expand Down
26 changes: 20 additions & 6 deletions packages/core/src/Network/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { signChallenge } from './Authenticator';
import { genericErrorHandler } from './ErrorHandler';
import { b64RequestObject, urlize } from './utils';
import type { ProvisionalKeysRequest, SetVerificationMethodRequest, VerificationRequest } from '../LocalUser/requests';
import type { OidcAuthorizationCodeVerification } from '../LocalUser/types';
import type { PublicProvisionalIdentityTarget } from '../Identity/identity';
import type {
FileUploadURLResponse, FileDownloadURLResponse,
Expand Down Expand Up @@ -103,7 +104,7 @@ export class Client {
// Simple _fetch wrapper with:
// - proper headers set (sdk info and authorization)
// - generic error handling
_baseApiCall = async (path: string, init?: RequestInit): Promise<any> => {
_baseApiCall = async (path: string, authenticated: boolean, init?: RequestInit): Promise<any> => {
try {
if (!path || path[0] !== '/') {
throw new InvalidArgument('"path" should be non empty and start with "/"');
Expand All @@ -114,7 +115,7 @@ export class Client {
headers['X-Tanker-Sdktype'] = this._sdkType;
headers['X-Tanker-Sdkversion'] = this._sdkVersion;

if (this._accessToken) {
if (authenticated && this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`; // eslint-disable-line dot-notation
}

Expand Down Expand Up @@ -196,7 +197,7 @@ export class Client {
},
};

return retry(() => this._baseApiCall(path, init), retryOptions);
return retry(() => this._baseApiCall(path, true, init), retryOptions);
});

_authenticate = this._cancelable((): Promise<void> => {
Expand All @@ -216,14 +217,14 @@ export class Client {

const auth = async () => {
const { challenge } = await this._cancelable(
() => this._baseApiCall(`/devices/${urlize(deviceId)}/challenges`, { method: 'POST' }),
() => this._baseApiCall(`/devices/${urlize(deviceId)}/challenges`, false, { method: 'POST' }),
)();

const signature = signChallenge(deviceSignatureKeyPair, challenge);
const signaturePublicKey = deviceSignatureKeyPair.publicKey;

const { access_token: accessToken } = await this._cancelable(
() => this._baseApiCall(`/devices/${urlize(deviceId)}/sessions`, {
() => this._baseApiCall(`/devices/${urlize(deviceId)}/sessions`, false, {
method: 'POST',
body: JSON.stringify(b64RequestObject({ signature, challenge, signature_public_key: signaturePublicKey })),
headers: { 'Content-Type': 'application/json' },
Expand Down Expand Up @@ -446,6 +447,19 @@ export class Client {
return challenge;
};

oidcSignIn = async (oidcProviderId: string): Promise<OidcAuthorizationCodeVerification> => {
const { code, state } = await this._baseApiCall(
`/oidc/${oidcProviderId}/signin?user_id=${urlize(this._userId)}`,
false,
{ credentials: 'include' },
);
return {
oidcProviderId,
oidcAuthorizationCode: code,
oidcState: state,
};
};

getResourceKey = async (resourceId: Uint8Array): Promise<b64string | null> => {
const query = `resource_ids[]=${urlize(resourceId)}`;
const { resource_keys: resourceKeys } = await this._apiCall(`/resource-keys?${query}`);
Expand Down Expand Up @@ -613,7 +627,7 @@ export class Client {
// 204: session successfully deleted
// 401: session already expired
// other: something unexpected happened -> ignore and continue closing ¯\_(ツ)_/¯
await this._baseApiCall(path, { method: 'DELETE' }).catch((error: TankerError) => {
await this._baseApiCall(path, true, { method: 'DELETE' }).catch((error: TankerError) => {
if (error.httpStatus !== 401) {
console.error('Error while closing the network client', error);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/Network/ErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ const apiCodeErrorMap: Record<string, Class<TankerError>> = {
empty_user_group: InvalidArgument,
feature_not_enabled: PreconditionFailed,
group_too_big: GroupTooBig,
invalid_authorization_code: InvalidVerification,
invalid_delegation_signature: InvalidVerification,
invalid_oidc_id_token: InvalidVerification,
invalid_passphrase: InvalidVerification,
invalid_token: PreconditionFailed, // invalid or expired access token
invalid_verification_code: InvalidVerification,
missing_user_group_members: InvalidArgument,
not_a_user_group_member: InvalidArgument,
oidc_provider_interaction_required: PreconditionFailed,
oidc_provider_not_configured: PreconditionFailed,
oidc_provider_not_supported: PreconditionFailed,
provisional_identity_already_attached: IdentityAlreadyAttached,
too_many_attempts: TooManyAttempts,
too_many_requests: TooManyRequests,
Expand All @@ -22,7 +26,6 @@ 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
1 change: 1 addition & 0 deletions packages/core/src/Session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export class Session extends EventEmitter {
setVerificationMethod = this._forward(this._getLocalUserManager, 'setVerificationMethod');
getVerificationMethods = this._forward(this._getLocalUserManager, 'getVerificationMethods');
generateVerificationKey = this._forward(this._getLocalUserManager, 'generateVerificationKey');
createOidcAuthorizationCode = this._forward(this._getLocalUserManager, 'createOidcAuthorizationCode');

getSessionToken = this._forward(this._getLocalUserManager, 'getSessionToken');

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/Tanker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
PhoneNumberVerification,
LegacyEmailVerificationMethod,
PreverifiedVerification,
OidcAuthorizationCodeVerification,
} from './LocalUser/types';
import { extractUserData } from './LocalUser/UserData';

Expand Down Expand Up @@ -632,4 +633,11 @@ export class Tanker extends EventEmitter {

return this.session.createEncryptionSession(encryptionOptions);
}

// authenticateWithIdP() is only exposed in client-browser because it relies on Cookies
// and Cookies are not handled by node fetch
async _authenticateWithIdP(oidcProviderId: string): Promise<OidcAuthorizationCodeVerification> {
assertStatus(this.status, [statuses.IDENTITY_REGISTRATION_NEEDED, statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'authenticate with an Identity provider');
return this.session.createOidcAuthorizationCode(oidcProviderId);
}
}
15 changes: 15 additions & 0 deletions packages/core/src/__tests__/Tanker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ describe('Tanker', () => {
{ passphrase: 12 },
{ passphrase: new Uint8Array(12) },
{ passphrase: '' },
{ oidcProviderId: '', oidcAuthorizationCode: 'code', oidcState: 'state' },
{ oidcProviderId: 'issuer', oidcAuthorizationCode: '', oidcState: 'state' },
{ oidcProviderId: 'issuer', oidcAuthorizationCode: 'code', oidcState: '' },
{ oidcProviderId: 'issuer', oidcAuthorizationCode: new Uint8Array(12), oidcState: 'state' },
{ oidcProviderId: 'issuer', oidcAuthorizationCode: 'code' },
{ oidcAuthorizationCode: 'code', oidcState: 'state' },
{ oidcProviderId: 'issuer', oidcState: 'state' },
{ email: '[email protected]', verificationCode: '12345678', passphrase: 'valid_passphrase' }, // only one method at a time!
];

Expand Down Expand Up @@ -218,6 +225,14 @@ describe('Tanker', () => {
});
});

describe('authenticateWithIdP', () => {
it('throws when tanker is STOPPED', async () => {
// We are testing a private method (only public in client-browser)
// eslint-disable-next-line no-underscore-dangle
await expect(tanker._authenticateWithIdP('SomeBase64')).to.be.rejectedWith(PreconditionFailed);
});
});

describe('enrollUser', () => {
it('throws when tanker is not STOPPED', async () => {
const illegalStatuses = [
Expand Down
6 changes: 3 additions & 3 deletions packages/functional-tests/src/enroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ export const generateEnrollTests = (args: TestArgs) => {
let emailVerification: PreverifiedEmailVerification;
let phoneNumberVerification: PreverifiedPhoneNumberVerification;
let oidcVerification: PreverifiedOidcVerification;
let providerID: string;
let providerId: string;

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

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

emailVerification = {
preverifiedEmail: email,
Expand All @@ -34,7 +34,7 @@ export const generateEnrollTests = (args: TestArgs) => {
preverifiedPhoneNumber: phoneNumber,
};
oidcVerification = {
oidcProviderId: providerID,
oidcProviderId: providerId,
preverifiedOidcSubject: 'a subject',
};
});
Expand Down
4 changes: 3 additions & 1 deletion packages/functional-tests/src/helpers/AppHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,18 @@ export class AppHelper {
});
}

async setOidc(provider: 'google' | 'pro-sante-bas' | 'pro-sante-bas-no-expiry' = 'google') {
async setOidc(provider: 'google' | 'pro-sante-bas' | 'pro-sante-bas-no-expiry' | 'fake-oidc' = 'google') {
const providers = {
google: oidcSettings.googleAuth.clientId,
'pro-sante-bas': 'doctolib-dev',
'pro-sante-bas-no-expiry': 'doctolib-dev',
'fake-oidc': 'tanker',
};
const providersIssuer = {
google: 'https://accounts.google.com',
'pro-sante-bas': 'https://auth.bas.psc.esante.gouv.fr/auth/realms/esante-wallet',
'pro-sante-bas-no-expiry': 'https://auth.bas.psc.esante.gouv.fr/auth/realms/esante-wallet',
'fake-oidc': `${oidcSettings.fakeOidc.url}/issuer`,
};

return this._update({
Expand Down
3 changes: 3 additions & 0 deletions packages/functional-tests/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const oidcSettings = {
},
},
},
fakeOidc: {
url: process.env['TANKER_FAKE_OIDC_URL'] || '',
},
};
const storageSettings = {
s3: {
Expand Down
Loading

0 comments on commit 89ff21f

Please sign in to comment.