Skip to content

Validate 'aud' in DID Token #111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,78 @@
# v2.0.0 (July 10, 2023)

## Summary
- 🚀 **Added:** Magic Connect developers can now use the Admin SDK to validate DID tokens. [#111](https://github.com/magiclabs/magic-admin-js/pull/111) ([@magic-ravi](https://github.com/magic-ravi))
- ⚠️ **Changed:** After creating the Magic instance, it is now necessary to call a new initialize method for Magic Connect developers that want to utilize the Admin SDK. [#111](https://github.com/magiclabs/magic-admin-js/pull/111) ([@magic-ravi](https://github.com/magic-ravi))
- 🛡️ **Security:** Additional validation of `aud` (client ID) is now being done during initialization of the SDK. [#111](https://github.com/magiclabs/magic-admin-js/pull/111) ([@magic-ravi](https://github.com/magic-ravi))

## Developer Notes

### 🚀 Added

#### Admin SDK for MC
Magic Connect developers can now use the Admin SDK to validate DID tokens.

**Details**
There is full support for all `TokenResource` SDK methods for MC. This is intended to be used with client side `magic-js` SDK which will now emit an `id-token-created` event with a DID token upon login via the `connectWithUI` method.

This functionality is replicated on our other SDKs on Python and Ruby.

### ⚠️ Changed

#### Constructor initialization

The existing constructor has been deprecated in place of a new async `init` method.
The `init` method will pull clientId from Magic servers if one is not provided in the `options` parameter.

**Previous Version**
```javascript
const magic = new Magic(secretKey);
try {
magic.token.validate(DIDT);
} catch (e) {
console.log(e);
}
try {
await magic.users.getMetadataByToken(DIDT);
} catch (e) {
console.log(e);
}
```

**Current Version**
```javascript
const magic = await Magic.init(mcSecretKey);
try {
magic.token.validate(DIDT);
} catch (e) {
console.log(e);
}
try {
await magic.users.getMetadataByToken(DIDT);
} catch (e) {
console.log(e);
}
```

#### Attachment Validation

- Skip validation of attachment if 'none' is passed in `validate`.

### 🛡️ Security

#### Client ID Validation

Additional validation of `aud` (client ID) is now being done during initialization of the SDK. This is for both Magic Connect and Magic Auth developers.


### 🚨 Breaking

None, all changes are fully backwards compatiable.

### Authors: 1

- Ravi Bhankharia ([@magic-ravi](https://github.com/magic-ravi))

# v1.10.1 (Fri Jul 07 2023)

#### 🐛 Bug Fix
Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,22 @@ Sign up or log in to the [developer dashboard](https://dashboard.magic.link) to
```ts
const { Magic } = require('@magic-sdk/admin');

const magic = new Magic('YOUR_SECRET_API_KEY');

// Read the docs to learn about next steps! 🚀
// In async function:
const magic = await Magic.init('YOUR_SECRET_API_KEY');
// OR
Magic.init('YOUR_SECRET_API_KEY').then((magic) => {
magic
});
// Validate a token
try {
magic.token.validate("DIDToken");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: this can be extracted into a variable

} catch (e) {
console.log(e);
}
// Magic Auth - Get User Email
try {
await magic.users.getMetadataByToken("DIDToken");
} catch (e) {
console.log(e);
}
```
9 changes: 8 additions & 1 deletion src/core/sdk-exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function createFailedRecoveringProofError() {
export function createApiKeyMissingError() {
return new MagicAdminSDKError(
ErrorCode.ApiKeyMissing,
'Please provide a secret Fortmatic API key that you acquired from the developer dashboard.',
'Please provide a secret Magic API key that you acquired from the developer dashboard.',
);
}

Expand All @@ -63,3 +63,10 @@ export function createExpectedBearerStringError() {
'Expected argument to be a string in the `Bearer {token}` format.',
);
}

export function createAudienceMismatchError() {
return new MagicAdminSDKError(
ErrorCode.AudienceMismatch,
'Audience does not match client ID. Please ensure your secret key matches the application which generated the DID token.',
);
}
33 changes: 32 additions & 1 deletion src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { TokenModule } from '../modules/token';
import { UsersModule } from '../modules/users';
import { UtilsModule } from '../modules/utils';
import { MagicAdminSDKAdditionalConfiguration } from '../types';
import { get } from '../utils/rest';
import { createApiKeyMissingError } from './sdk-exceptions';

export class MagicAdminSDK {
public readonly apiBaseUrl: string;
Expand All @@ -24,13 +26,42 @@ export class MagicAdminSDK {
*/
public readonly utils: UtilsModule;

/**
* Unique client identifier
*/
public clientId: string | null;

/**
* Deprecated. Use `init` instead.
* @param secretApiKey
* @param options
*/
constructor(public readonly secretApiKey?: string, options?: MagicAdminSDKAdditionalConfiguration) {
const endpoint = options?.endpoint ?? 'https://api.magic.link';
this.apiBaseUrl = endpoint.replace(/\/+$/, '');

this.clientId = options?.clientId ?? null;
// Assign API Modules
this.token = new TokenModule(this);
this.users = new UsersModule(this);
this.utils = new UtilsModule(this);
}

public static async init(secretApiKey?: string, options?: MagicAdminSDKAdditionalConfiguration) {
if (!secretApiKey) throw createApiKeyMissingError();

let hydratedOptions = options ?? {};

const endpoint = hydratedOptions.endpoint ?? 'https://api.magic.link';
const apiBaseUrl = endpoint.replace(/\/+$/, '');

if (!hydratedOptions.clientId) {
const resp = await get<{
client_id: string | null;
app_scope: string | null;
}>(`${apiBaseUrl}/v1/admin/client/get`, secretApiKey);
hydratedOptions = { ...hydratedOptions, clientId: resp.client_id };
}

return new MagicAdminSDK(secretApiKey, hydratedOptions);
}
}
14 changes: 11 additions & 3 deletions src/modules/token/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createTokenExpiredError,
createMalformedTokenError,
createTokenCannotBeUsedYetError,
createAudienceMismatchError,
} from '../../core/sdk-exceptions';
import { ecRecover } from '../../utils/ec-recover';
import { parseDIDToken } from '../../utils/parse-didt';
Expand All @@ -15,7 +16,7 @@ import { parsePublicAddressFromIssuer } from '../../utils/issuer';
export class TokenModule extends BaseModule {
public validate(DIDToken: string, attachment = 'none') {
let tokenSigner = '';
let attachmentSigner = '';
let attachmentSigner: string | null = null;
let claimedIssuer = '';
let parsedClaim;
let proof: string;
Expand All @@ -35,13 +36,15 @@ export class TokenModule extends BaseModule {
tokenSigner = ecRecover(claim, proof).toLowerCase();

// Recover the attachment signer
attachmentSigner = ecRecover(attachment, parsedClaim.add).toLowerCase();
if (attachment && attachment !== 'none') {
attachmentSigner = ecRecover(attachment, parsedClaim.add).toLowerCase();
}
} catch {
throw createFailedRecoveringProofError();
}

// Assert the expected signer
if (claimedIssuer !== tokenSigner || claimedIssuer !== attachmentSigner) {
if (claimedIssuer !== tokenSigner || (attachmentSigner && claimedIssuer !== attachmentSigner)) {
throw createIncorrectSignerAddressError();
}

Expand All @@ -57,6 +60,11 @@ export class TokenModule extends BaseModule {
if (parsedClaim.nbf - nbfLeeway > timeSecs) {
throw createTokenCannotBeUsedYetError();
}

// Assert the audience matches the client ID.
if (this.sdk.clientId && parsedClaim.aud !== this.sdk.clientId) {
throw createAudienceMismatchError();
}
}

public decode(DIDToken: string): ParsedDIDToken {
Expand Down
1 change: 1 addition & 0 deletions src/types/exception-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export enum ErrorCode {
MalformedTokenError = 'ERROR_MALFORMED_TOKEN',
ServiceError = 'SERVICE_ERROR',
ExpectedBearerString = 'EXPECTED_BEARER_STRING',
AudienceMismatch = 'ERROR_AUDIENCE_MISMATCH',
}
1 change: 1 addition & 0 deletions src/types/sdk-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface MagicAdminSDKAdditionalConfiguration {
endpoint?: string;
clientId?: string | null;
}

export interface MagicWallet {
Expand Down
3 changes: 3 additions & 0 deletions test/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ export const INVALID_SIGNER_DIDT =

export const EXPIRED_DIDT =
'WyIweGE3MDUzYzg3OTI2ZjMzZDBjMTZiMjMyYjYwMWYxZDc2NmRiNWY3YWM4MTg2MzUyMzY4ZjAyMzIyMGEwNzJjYzkzM2JjYjI2MmU4ODQyNWViZDA0MzcyZGU3YTc0NzMwYjRmYWYzOGU0ZjgwNmYzOTJjMTVkNzY2YmVkMjVlZmUxMWIiLCJ7XCJpYXRcIjoxNTg1MDEwODM1LFwiZXh0XCI6MTU4NTAxMDgzNixcImlzc1wiOlwiZGlkOmV0aHI6MHhCMmVjOWI2MTY5OTc2MjQ5MWI2NTQyMjc4RTlkRkVDOTA1MGY4MDg5XCIsXCJzdWJcIjpcIjZ0RlhUZlJ4eWt3TUtPT2pTTWJkUHJFTXJwVWwzbTNqOERReWNGcU8ydHc9XCIsXCJhdWRcIjpcImRpZDptYWdpYzpkNGMwMjgxYi04YzViLTQ5NDMtODUwOS0xNDIxNzUxYTNjNzdcIixcIm5iZlwiOjE1ODUwMTA4MzUsXCJ0aWRcIjpcImFjMmE4YzFjLWE4OWEtNDgwOC1hY2QxLWM1ODg1ZTI2YWZiY1wiLFwiYWRkXCI6XCIweDkxZmJlNzRiZTZjNmJmZDhkZGRkZDkzMDExYjA1OWI5MjUzZjEwNzg1NjQ5NzM4YmEyMTdlNTFlMGUzZGYxMzgxZDIwZjUyMWEzNjQxZjIzZWI5OWNjYjM0ZTNiYzVkOTYzMzJmZGViYzhlZmE1MGNkYjQxNWU0NTUwMDk1MmNkMWNcIn0iXQ==';

export const VALID_ATTACHMENT_DIDT =
'WyIweGVkMWMwNWRlMTVlMWFkY2Y5ZmEyZWNkNjVjZjg5NWMzYTgzMzQ2OGMwOGFhMmE3YjQ5ZDgyMjFiZWEyMWU1YjgzNDRiNWEwMzAzNmQxMzA5MzQyNTgzMWIxZTFjZGIwZWQ2NTgyMDI4MWU1NzhlMjU5ODJhYzdkYmNkZWJhN2I1MWMiLCJ7XCJpYXRcIjoxNjg4MDYzMTA4LFwiZXh0XCI6MS4wMDAwMDAwMDAwMDE2ODgxZSsyMSxcImlzc1wiOlwiZGlkOmV0aHI6MHhhMWI0YzA5NDI2NDdlNzkwY0ZEMmEwNUE1RkQyNkMwMmM0MjEzOWFlXCIsXCJzdWJcIjpcIjhaTUJnOXNwMFgwQ0FNanhzcVFaOGRzRTJwNVlZWm9lYkRPeWNPUFNNbDA9XCIsXCJhdWRcIjpcIjN3X216VmktaDNtUzc3cFZ4b19ydlJhWjR2WXpOZ0Vudm05ZGcwWnkzYzg9XCIsXCJuYmZcIjoxNjg4MDYzMTA4LFwidGlkXCI6XCJjM2U5ZWRiYy04MDU2LTQ3NGItOGFkMy1hOGI2MzM3NThlOTRcIixcImFkZFwiOlwiMHgzZGExZTM3MmU1ZWU5MjI4YzdlYjBkNmQwZDE2MTAxZjBkNjE5MDY0ODVhYjgzNDMzNWI3Y2YxOGE5ZDNmZWEzNjRmYzFjMTFiNzRlYzBhNTQ0ZTkzNmJkNjQ1Y2U3ZDdkZTIyMTRlNTJlYjZhOThjZTIyNzI1OTEwNDg0ZjJkOTFjXCJ9Il0';
4 changes: 2 additions & 2 deletions test/lib/factories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { API_FULL_URL, API_KEY } from './constants';
import { MagicAdminSDK } from '../../src/core/sdk';

export function createMagicAdminSDK(endpoint = API_FULL_URL) {
return new MagicAdminSDK(API_KEY, { endpoint });
export function createMagicAdminSDK(endpoint = API_FULL_URL, clientId = null) {
return new MagicAdminSDK(API_KEY, { endpoint, clientId });
}
12 changes: 11 additions & 1 deletion test/spec/core/sdk-exceptions/error-factories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createServiceError,
createExpectedBearerStringError,
createTokenCannotBeUsedYetError,
createAudienceMismatchError,
} from '../../../../src/core/sdk-exceptions';

function errorAssertions(
Expand Down Expand Up @@ -55,7 +56,7 @@ test('Creates `ERROR_SECRET_API_KEY_MISSING` error', async () => {
errorAssertions(
error,
'ERROR_SECRET_API_KEY_MISSING',
'Please provide a secret Fortmatic API key that you acquired from the developer dashboard.',
'Please provide a secret Magic API key that you acquired from the developer dashboard.',
);
});

Expand All @@ -82,3 +83,12 @@ test('Creates `EXPECTED_BEARER_STRING` error', async () => {
const error = createExpectedBearerStringError();
errorAssertions(error, 'EXPECTED_BEARER_STRING', 'Expected argument to be a string in the `Bearer {token}` format.');
});

test('Creates `AUDIENCE_MISMATCH` error', async () => {
const error = createAudienceMismatchError();
errorAssertions(
error,
'ERROR_AUDIENCE_MISMATCH',
'Audience does not match client ID. Please ensure your secret key matches the application which generated the DID token.',
);
});
61 changes: 61 additions & 0 deletions test/spec/core/sdk/constructor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { API_FULL_URL, API_KEY } from '../../../lib/constants';
import { TokenModule } from '../../../../src/modules/token';
import { UsersModule } from '../../../../src/modules/users';
import { UtilsModule } from '../../../../src/modules/utils';
import { get } from '../../../../src/utils/rest';
import { createApiKeyMissingError } from '../../../../src/core/sdk-exceptions';

test('Initialize `MagicAdminSDK`', () => {
const magic = new Magic(API_KEY);
Expand Down Expand Up @@ -33,3 +35,62 @@ test('Strips trailing slash(es) from custom endpoint argument', () => {
expect(magicB.apiBaseUrl).toBe('https://example.com');
expect(magicC.apiBaseUrl).toBe('https://example.com');
});

test('Initialize `MagicAdminSDK` using static init and empty options', async () => {
const successRes = Promise.resolve({
client_id: 'foo',
app_scope: 'GLOBAL',
});
(get as any) = jest.fn().mockImplementation(() => successRes);

const magic = await Magic.init(API_KEY, {});

expect(magic.secretApiKey).toBe(API_KEY);
expect(magic.apiBaseUrl).toBe(API_FULL_URL);
expect(magic.token instanceof TokenModule).toBe(true);
expect(magic.users instanceof UsersModule).toBe(true);
});

test('Initialize `MagicAdminSDK` using static init and undefined options', async () => {
const successRes = Promise.resolve({
client_id: 'foo',
app_scope: 'GLOBAL',
});
(get as any) = jest.fn().mockImplementation(() => successRes);

const magic = await Magic.init(API_KEY);

expect(magic.secretApiKey).toBe(API_KEY);
expect(magic.apiBaseUrl).toBe(API_FULL_URL);
expect(magic.token instanceof TokenModule).toBe(true);
expect(magic.users instanceof UsersModule).toBe(true);
});

test('Initialize `MagicAdminSDK` using static init and client ID', async () => {
const magic = await Magic.init(API_KEY, { clientId: '1234' });

expect(magic.secretApiKey).toBe(API_KEY);
expect(magic.apiBaseUrl).toBe(API_FULL_URL);
expect(magic.token instanceof TokenModule).toBe(true);
expect(magic.users instanceof UsersModule).toBe(true);
});

test('Initialize `MagicAdminSDK` using static init and endpoint', async () => {
const successRes = Promise.resolve({
client_id: 'foo',
app_scope: 'GLOBAL',
});
(get as any) = jest.fn().mockImplementation(() => successRes);

const magic = await Magic.init(API_KEY, { endpoint: 'https://example.com' });

expect(magic.secretApiKey).toBe(API_KEY);
expect(magic.apiBaseUrl).toBe('https://example.com');
expect(magic.token instanceof TokenModule).toBe(true);
expect(magic.users instanceof UsersModule).toBe(true);
});

test('Initialize `MagicAdminSDK` missing API Key', async () => {
const expectedError = createApiKeyMissingError();
expect(Magic.init(null, { clientId: '1234' })).rejects.toThrow(expectedError);
});
18 changes: 18 additions & 0 deletions test/spec/modules/token/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,32 @@ import {
EXPIRED_DIDT,
INVALID_DIDT_MALFORMED_CLAIM,
VALID_FUTURE_MARKED_DIDT,
VALID_ATTACHMENT_DIDT,
} from '../../../lib/constants';
import {
createIncorrectSignerAddressError,
createTokenExpiredError,
createFailedRecoveringProofError,
createMalformedTokenError,
createTokenCannotBeUsedYetError,
createAudienceMismatchError,
} from '../../../../src/core/sdk-exceptions';

test('Successfully validates DIDT', async () => {
const sdk = createMagicAdminSDK(undefined, 'did:magic:f54168e9-9ce9-47f2-81c8-7cb2a96b26ba');
expect(() => sdk.token.validate(VALID_DIDT)).not.toThrow();
});

test('Successfully validates DIDT without checking audience', async () => {
const sdk = createMagicAdminSDK();
expect(() => sdk.token.validate(VALID_DIDT)).not.toThrow();
});

test('Successfully validates DIDT with attachment', async () => {
const sdk = createMagicAdminSDK();
expect(() => sdk.token.validate(VALID_ATTACHMENT_DIDT, 'ravi@magic.link')).not.toThrow();
});

test('Fails when signer address mismatches signature', async () => {
const sdk = createMagicAdminSDK();
const expectedError = createIncorrectSignerAddressError();
Expand Down Expand Up @@ -49,3 +61,9 @@ test('Fails if decoding token fails', async () => {
const expectedError = createMalformedTokenError();
expect(() => sdk.token.validate(INVALID_DIDT_MALFORMED_CLAIM)).toThrow(expectedError);
});

test('Fails if aud is incorrect', async () => {
const sdk = createMagicAdminSDK(undefined, 'different');
const expectedError = createAudienceMismatchError();
expect(() => sdk.token.validate(VALID_DIDT)).toThrow(expectedError);
});