Skip to content
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

Allow reusing SIWE messages in EIP4361-based condition authentication #529

Merged
merged 5 commits into from
Jul 1, 2024
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
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
Comment on lines +37 to +38
Copy link
Member

Choose a reason for hiding this comment

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

Why not delete these lines instead of commenting them out?

Copy link
Contributor Author

@piotr-roslaniec piotr-roslaniec Jun 24, 2024

Choose a reason for hiding this comment

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

I thought it was more explicit (explicitly disabled), is it confusing? Maybe an assertion is better.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe an assertion is better.

Interesting. Where would you put the assertion?

Alternatively, maybe keep the comment on L37 and remove L38?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

An assertion could be a statement in this file that gets executed when the file is imported. That's a bit ham-fisted, I need to think about this. In the meantime, I will remove L38.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed - the assertion seems too heavy.

];
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]) {
Copy link
Member

Choose a reason for hiding this comment

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

For this case, should we also check that the customParameters[param] value is an AuthSignature? So not just that it is present in the custom parameters, but its type is correct. Or is that already done somewhere else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we validate it against the structure of AuthSignature. We may want to use zod for that. Let's see.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Documented in #535, will address in a follow-up PR

// 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
}
Comment on lines +144 to 146
Copy link
Member

Choose a reason for hiding this comment

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

Is the empty block for a comment something common in typescript? Perhaps just the comment?

Suggested change
} else {
// Not a context parameter, we can skip
}
}
// else not a context parameter, we can skip

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's no TS-related, just something I sometimes do to be more explicit.

}

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',
};
Loading