Skip to content

Commit

Permalink
Allow reusing SIWE messages in EIP4361-based condition authentication (
Browse files Browse the repository at this point in the history
  • Loading branch information
piotr-roslaniec committed Jul 1, 2024
2 parents 2a1a25b + f77f973 commit 4eea8e7
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 47 deletions.
10 changes: 7 additions & 3 deletions packages/taco-auth/src/helper.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import {ethers} from "ethers";

import { EIP4361AuthProvider } from './providers/eip4361';
import { EIP4361AuthProvider, EIP4361AuthProviderParams } from './providers/eip4361';
import { EIP712AuthProvider } from './providers/eip712';
import { AuthProviders, EIP4361_AUTH_METHOD, EIP712_AUTH_METHOD } from './types';

export const makeAuthProviders = (provider: ethers.providers.Provider, signer?: ethers.Signer): AuthProviders => {
export const makeAuthProviders = (
provider: ethers.providers.Provider,
signer?: ethers.Signer,
siweDefaultParams?: EIP4361AuthProviderParams
): AuthProviders => {
return {
[EIP712_AUTH_METHOD]: signer ? new EIP712AuthProvider(provider, signer) : undefined,
[EIP4361_AUTH_METHOD]: signer ? new EIP4361AuthProvider(provider, signer) : undefined
[EIP4361_AUTH_METHOD]: signer ? new EIP4361AuthProvider(provider, signer, siweDefaultParams) : undefined
} as AuthProviders;
};
25 changes: 19 additions & 6 deletions packages/taco-auth/src/providers/eip4361.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ import { AuthSignature, EIP4361_AUTH_METHOD } from '../types';

export type EIP4361TypedData = string;

export type EIP4361AuthProviderParams = {
domain: string;
uri: string;
}

const ERR_MISSING_SIWE_PARAMETERS = 'Missing default SIWE parameters';

export class EIP4361AuthProvider {
private readonly storage: LocalStorage;

constructor(
// TODO: We only need the provider to fetch the chainId, consider removing it
private readonly provider: ethers.providers.Provider,
private readonly signer: ethers.Signer,
private readonly providerParams?: EIP4361AuthProviderParams,
) {
this.storage = new LocalStorage();
}
Expand Down Expand Up @@ -55,7 +63,10 @@ export class EIP4361AuthProvider {
}

// TODO: Create a facility to set these parameters or expose them to the user
private getParametersOrDefault() {
private getParametersOrDefault(): {
domain: string;
uri: string;
} {
// If we are in a browser environment, we can get the domain and uri from the window object
if (typeof window !== 'undefined') {
const maybeOrigin = window?.location?.origin;
Expand All @@ -64,10 +75,12 @@ export class EIP4361AuthProvider {
uri: maybeOrigin,
};
}
// TODO: Add a facility to manage this case
return {
domain: 'localhost',
uri: 'http://localhost:3000',
};
if (this.providerParams) {
return {
domain: this.providerParams.domain,
uri: this.providerParams.uri,
}
}
throw new Error(ERR_MISSING_SIWE_PARAMETERS);
}
}
4 changes: 2 additions & 2 deletions packages/taco-auth/test/taco-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
bobSecretKeyBytes,
fakeProvider,
fakeSigner,
fakeSigner, TEST_SIWE_PARAMS,
} from '@nucypher/test-utils';
import { SiweMessage } from 'siwe';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -43,7 +43,7 @@ describe('taco authorization', () => {
const provider = fakeProvider(bobSecretKeyBytes);
const signer = fakeSigner(bobSecretKeyBytes);

const eip4361Provider = new EIP4361AuthProvider(provider, signer);
const eip4361Provider = new EIP4361AuthProvider(provider, signer, TEST_SIWE_PARAMS);
const typedSignature = await eip4361Provider.getOrCreateAuthSignature();
expect(typedSignature.signature).toBeDefined();
expect(typedSignature.address).toEqual(await signer.getAddress());
Expand Down
8 changes: 7 additions & 1 deletion packages/taco/src/conditions/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
USER_ADDRESS_PARAM_EIP712
} from "@nucypher/taco-auth";

export const USER_ADDRESS_PARAM_EXTERNAL_EIP4361 =
':userAddressExternalEIP4361';

export const ETH_ADDRESS_REGEXP = new RegExp('^0x[a-fA-F0-9]{40}$');

// Only allow alphanumeric characters and underscores
Expand All @@ -22,12 +25,15 @@ export const SUPPORTED_CHAIN_IDS = [
export const USER_ADDRESS_PARAMS = [
USER_ADDRESS_PARAM_EIP712,
USER_ADDRESS_PARAM_EIP4361,
// this should always be last
USER_ADDRESS_PARAM_EXTERNAL_EIP4361,
// Ordering matters, this should always be last
USER_ADDRESS_PARAM_DEFAULT,
];

export const RESERVED_CONTEXT_PARAMS = [
USER_ADDRESS_PARAM_DEFAULT,
USER_ADDRESS_PARAM_EIP712,
USER_ADDRESS_PARAM_EIP4361,
// USER_ADDRESS_PARAM_EXTERNAL_EIP4361 is not reserved and can be used as a custom context parameter
// USER_ADDRESS_PARAM_EXTERNAL_EIP4361
];
48 changes: 33 additions & 15 deletions packages/taco/src/conditions/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ import { CoreConditions, CoreContext } from '../../types';
import { CompoundConditionType } from '../compound-condition';
import { Condition, ConditionProps } from '../condition';
import { ConditionExpression } from '../condition-expr';
import { CONTEXT_PARAM_PREFIX, CONTEXT_PARAM_REGEXP, RESERVED_CONTEXT_PARAMS } from '../const';
import { CONTEXT_PARAM_PREFIX, CONTEXT_PARAM_REGEXP, RESERVED_CONTEXT_PARAMS, USER_ADDRESS_PARAMS } from '../const';


export type CustomContextParam = string | number | boolean;
export type CustomContextParam = string | number | boolean | AuthSignature;
export type ContextParam = CustomContextParam | AuthSignature;

const ERR_RESERVED_PARAM = (key: string) =>
`Cannot use reserved parameter name ${key} as custom parameter`;
const ERR_INVALID_CUSTOM_PARAM = (key: string) =>
`Custom parameter ${key} must start with ${CONTEXT_PARAM_PREFIX}`;
const ERR_AUTH_PROVIDER_REQUIRED = (key: string) =>
`Authentication provider required to satisfy ${key} context variable in condition`;
`No matching authentication provider to satisfy ${key} context variable in condition`;
const ERR_MISSING_CONTEXT_PARAMS = (params: string[]) =>
`Missing custom context parameter(s): ${params.join(', ')}`;
const ERR_UNKNOWN_CONTEXT_PARAMS = (params: string[]) =>
`Unknown custom context parameter(s): ${params.join(', ')}`;
const ERR_NO_AUTH_PROVIDER_FOR_PARAM = (param: string) =>
`No custom parameter for requested context parameter: ${param}`;

export class ConditionContext {
public requestedParameters: Set<string>;
Expand Down Expand Up @@ -86,16 +88,24 @@ export class ConditionContext {
}

private validateAuthProviders(requestedParameters: Set<string>): void {
requestedParameters
.forEach(param => {
const maybeAuthMethod = AUTH_METHOD_FOR_PARAM[param];
if (!maybeAuthMethod) {
return;
}
if (!this.authProviders[maybeAuthMethod]) {
throw new Error(ERR_AUTH_PROVIDER_REQUIRED(param));
}
});
for (const param of requestedParameters) {
// If it's not a user address parameter, we can skip
if (!USER_ADDRESS_PARAMS.includes(param)) {
continue;
}

// If it's a user address parameter, we need to check if we have an auth provider
const authMethod = AUTH_METHOD_FOR_PARAM[param];
if (!authMethod && !this.customParameters[param]) {
// If we don't have an auth method, and we don't have a custom parameter, we have a problem
throw new Error(ERR_NO_AUTH_PROVIDER_FOR_PARAM(param));
}

// If we have an auth method, but we don't have an auth provider, we have a problem
if (authMethod && !this.authProviders[authMethod]) {
throw new Error(ERR_AUTH_PROVIDER_REQUIRED(param));
}
}
}

private async fillAuthContextParameters(requestedParameters: Set<string>): Promise<Record<string, ContextParam>> {
Expand All @@ -119,12 +129,20 @@ export class ConditionContext {
// First, we want to find all the parameters we need to add
const requestedParameters = new Set<string>();

// Search conditions for parameters
// Check return value test
if (condition.returnValueTest) {
const rvt = condition.returnValueTest.value;
if (ConditionContext.isContextParameter(rvt)) {
// Return value test can be a single parameter or an array of parameters
if (Array.isArray(rvt)) {
rvt.forEach((value) => {
if (ConditionContext.isContextParameter(value)) {
requestedParameters.add(value);
}
});
} else if (ConditionContext.isContextParameter(rvt)) {
requestedParameters.add(rvt);
} else {
// Not a context parameter, we can skip
}
}

Expand Down
96 changes: 76 additions & 20 deletions packages/taco/test/conditions/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { initialize } from '@nucypher/nucypher-core';
import {
AuthProviders,
AuthSignature,
AuthSignature, EIP4361_AUTH_METHOD,
EIP4361AuthProvider,
EIP712AuthProvider, EIP712TypedData,
makeAuthProviders,
Expand All @@ -11,7 +11,7 @@ import {
USER_ADDRESS_PARAM_EIP4361,
USER_ADDRESS_PARAM_EIP712
} from "@nucypher/taco-auth";
import {fakeAuthProviders, fakeProvider, fakeSigner} from '@nucypher/test-utils';
import { fakeAuthProviders, fakeProvider, fakeSigner, TEST_SIWE_PARAMS } from '@nucypher/test-utils';
import { ethers } from 'ethers';
import { beforeAll, describe, expect, it, vi } from 'vitest';

Expand All @@ -24,6 +24,7 @@ import { RpcCondition } from '../../src/conditions/base/rpc';
import { ConditionExpression } from '../../src/conditions/condition-expr';
import {
RESERVED_CONTEXT_PARAMS,
USER_ADDRESS_PARAM_EXTERNAL_EIP4361,
} from '../../src/conditions/const';
import { CustomContextParam } from '../../src/conditions/context';
import {
Expand Down Expand Up @@ -151,7 +152,7 @@ describe('context', () => {
const conditionExpr = new ConditionExpression(condition);
expect(conditionExpr.buildContext({}, authProviders)).toBeDefined();
expect(() => conditionExpr.buildContext({})).toThrow(
`Authentication provider required to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
`No matching authentication provider to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
);
});

Expand All @@ -170,7 +171,7 @@ describe('context', () => {
const conditionExpr = new ConditionExpression(condition);
expect(conditionExpr.buildContext( {}, authProviders)).toBeDefined();
expect(() => conditionExpr.buildContext( {})).toThrow(
`Authentication provider required to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
`No matching authentication provider to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
);
});

Expand All @@ -195,7 +196,7 @@ describe('context', () => {
const condition = new ContractCondition(conditionObj);
const conditionExpr = new ConditionExpression(condition);
expect(() => conditionExpr.buildContext( {}, undefined)).toThrow(
`Authentication provider required to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
`No matching authentication provider to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
);
});

Expand All @@ -210,7 +211,7 @@ describe('context', () => {
const condition = new ContractCondition(conditionObj);
const conditionExpr = new ConditionExpression(condition);
expect(() => conditionExpr.buildContext( {}, undefined)).toThrow(
`Authentication provider required to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
`No matching authentication provider to satisfy ${USER_ADDRESS_PARAM_DEFAULT} context variable in condition`,
);
});

Expand Down Expand Up @@ -286,16 +287,34 @@ describe('context', () => {
});
});

describe('authentication provider', () => {
// TODO: Move to a separate file
describe('No authentication provider', () => {
let provider: ethers.providers.Provider;
let signer: ethers.Signer;
let authProviders: AuthProviders;

async function testEIP4361AuthSignature(authSignature: AuthSignature) {
expect(authSignature).toBeDefined();
expect(authSignature.signature).toBeDefined();
expect(authSignature.scheme).toEqual('EIP4361');

const signerAddress = await signer.getAddress();
expect(authSignature.address).toEqual(signerAddress);

expect(authSignature.typedData).toContain(
`localhost wants you to sign in with your Ethereum account:\n${signerAddress}`,
);
expect(authSignature.typedData).toContain('URI: http://localhost:3000');

const chainId = (await provider.getNetwork()).chainId;
expect(authSignature.typedData).toContain(`Chain ID: ${chainId}`);
}

beforeAll(async () => {
await initialize();
provider = fakeProvider();
signer = fakeSigner();
authProviders = makeAuthProviders(provider, signer);
authProviders = makeAuthProviders(provider, signer, TEST_SIWE_PARAMS);
});

it('throws an error if there is no auth provider', () => {
Expand All @@ -310,7 +329,7 @@ describe('authentication provider', () => {
const condition = new ContractCondition(conditionObj);
const conditionExpr = new ConditionExpression(condition);
expect(() => conditionExpr.buildContext( {}, {})).toThrow(
`Authentication provider required to satisfy ${userAddressParam} context variable in condition`,
`No matching authentication provider to satisfy ${userAddressParam} context variable in condition`,
);
});
});
Expand Down Expand Up @@ -397,23 +416,60 @@ describe('authentication provider', () => {
EIP4361AuthProvider.prototype,
'getOrCreateAuthSignature',
);

const authSignature = await makeAuthSignature(USER_ADDRESS_PARAM_EIP4361);
expect(authSignature).toBeDefined();
expect(authSignature.signature).toBeDefined();
expect(authSignature.scheme).toEqual('EIP4361');
await testEIP4361AuthSignature(authSignature);

const signerAddress = await signer.getAddress();
expect(authSignature.address).toEqual(signerAddress);
expect(eip4361Spy).toHaveBeenCalledOnce();
});

expect(authSignature.typedData).toContain(
`localhost wants you to sign in with your Ethereum account:\n${signerAddress}`,
it('supports reusing external eip4361', async () => {
// Because we are reusing an existing SIWE auth message, we have to pass it as a custom parameter
const authMessage = await makeAuthSignature(USER_ADDRESS_PARAM_EIP4361);
const customParams: Record<string, CustomContextParam> = {
[USER_ADDRESS_PARAM_EXTERNAL_EIP4361]: authMessage as CustomContextParam,
};

// Spying on the EIP4361 provider to make sure it's not called
const eip4361Spy = vi.spyOn(
EIP4361AuthProvider.prototype,
'getOrCreateAuthSignature',
);
expect(authSignature.typedData).toContain('URI: http://localhost:3000');

const chainId = (await provider.getNetwork()).chainId;
expect(authSignature.typedData).toContain(`Chain ID: ${chainId}`);
// Now, creating the condition context to run the actual test
const conditionObj = {
...testContractConditionObj,
returnValueTest: {
...testReturnValueTest,
value: USER_ADDRESS_PARAM_EXTERNAL_EIP4361,
},
};
const condition = new ContractCondition(conditionObj);
const conditionExpr = new ConditionExpression(condition);

expect(eip4361Spy).toHaveBeenCalledOnce();
// Make sure we remove the EIP4361 auth method from the auth providers first
delete authProviders[EIP4361_AUTH_METHOD];
// Should throw an error if we don't pass the custom parameter
expect(
() => conditionExpr.buildContext( {}, authProviders)
).toThrow(
`No custom parameter for requested context parameter: ${USER_ADDRESS_PARAM_EXTERNAL_EIP4361}`,
);

// Remembering to pass in customParams here:
const builtContext = conditionExpr.buildContext(
customParams,
authProviders,
);
const contextVars = await builtContext.toContextParameters();
expect(eip4361Spy).not.toHaveBeenCalledOnce();

// Now, we expect that the auth signature will be available in the context variables
const authSignature = contextVars[
USER_ADDRESS_PARAM_EXTERNAL_EIP4361
] as AuthSignature;
expect(authSignature).toBeDefined();
await testEIP4361AuthSignature(authSignature);
});
});

Expand Down
5 changes: 5 additions & 0 deletions packages/test-utils/src/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export const TEST_CONTRACT_ADDR = '0x0000000000000000000000000000000000000001';
export const TEST_CONTRACT_ADDR_2 =
'0x0000000000000000000000000000000000000002';
export const TEST_CHAIN_ID = ChainId.SEPOLIA;

export const TEST_SIWE_PARAMS = {
domain: 'localhost',
uri: 'http://localhost:3000',
};

0 comments on commit 4eea8e7

Please sign in to comment.