Skip to content

Commit

Permalink
Merge pull request #111 from magiclabs/ravi-sc80094-ValidateAud
Browse files Browse the repository at this point in the history
Validate 'aud' in DID Token
  • Loading branch information
magic-ravi authored Jul 10, 2023
2 parents 0ae68b8 + bc1e9b6 commit 1720c53
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 11 deletions.
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");
} 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);
});

0 comments on commit 1720c53

Please sign in to comment.