Skip to content

Commit 0fbce9b

Browse files
authored
Rework/restyle auth flow (#453)
* Rework auth flow * Expose id from PassKey class * Testing every permutation of methods and sequences * Re-order methods * Remove unused timeouts * Remove redundant method vs type * Introduce consts for passkey and passphrase * Document failure case * Comments and error handling
1 parent cd3ce51 commit 0fbce9b

File tree

4 files changed

+478
-103
lines changed

4 files changed

+478
-103
lines changed

typescript/packages/common-identity/src/pass-key.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ const ALGS: PublicKeyCredentialParameters[] = [
1515
{ type: "public-key", alg: -8 }, // ed25519
1616
{ type: "public-key", alg: -7 }, // es256
1717
{ type: "public-key", alg: -257 }, // rs256
18-
]
18+
];
1919

2020
export interface PassKeyGetOptions {
21-
mediation?: "conditional",
22-
userVerification?: "required" | "preferred" | "discouraged"
21+
mediation?: "conditional";
22+
userVerification?: "required" | "preferred" | "discouraged";
23+
allowCredentials?: PublicKeyCredentialDescriptor[];
2324
}
2425

2526
// A `PassKey` represents an authentication via a WebAuthn authenticator.
@@ -31,13 +32,20 @@ export class PassKey {
3132
this.credentials = credentials;
3233
}
3334

35+
id() {
36+
return this.credentials.id;
37+
}
38+
3439
// Generate a root key from a `PassKey`.
3540
// A root key identity is deterministically derived from a `PassKey`'s
3641
// PRF output, a 32-byte hash, which is used as ed25519 key material.
42+
// Note: Root keys can only be created from PassKeys obtained via PassKey.get()
3743
async createRootKey(): Promise<Identity> {
3844
let seed = this.prf();
3945
if (!seed) {
40-
throw new Error("common-identity: No prf found from PassKey");
46+
throw new Error(
47+
"common-identity: No PRF found. This PassKey appears to have just been created - root keys can only be generated from PassKeys obtained via PassKey.get()",
48+
);
4149
}
4250

4351
return await Identity.fromRaw(seed);
@@ -66,15 +74,15 @@ export class PassKey {
6674
// Different data is available within `PublicKeyCredentials` depending
6775
// on whether it was created or retrieved. We need the PRF assertion
6876
// only available on "get" requests, so we don't return a `PassKey` here.
69-
static async create(name: string, displayName: string): Promise<void> {
77+
static async create(name: string, displayName: string): Promise<PassKey> {
7078
const challenge = random(32);
7179
const userId = random(32);
7280
const user = {
7381
id: userId,
7482
name,
7583
displayName,
7684
};
77-
85+
7886
let publicKey: PublicKeyCredentialCreationOptions = {
7987
challenge,
8088
rp: { id: RP_ID, name: RP },
@@ -93,10 +101,10 @@ export class PassKey {
93101
userVerification: "preferred", // default
94102
},
95103
pubKeyCredParams: ALGS,
96-
extensions: { prf: { eval: { first: PRF_SALT }}},
104+
extensions: { prf: { eval: { first: PRF_SALT } } },
97105
timeout: TIMEOUT,
98106
};
99-
107+
100108
let result = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null;
101109
if (!result) {
102110
throw new Error("common-identity: Could not create passkey");
@@ -105,23 +113,29 @@ export class PassKey {
105113
if (!extResults?.prf?.enabled) {
106114
throw new Error("common-identity: prf extension not supported.");
107115
}
116+
117+
return new PassKey(result);
108118
}
109119

110120
// Retrieve a `PassKey` from a Web Authn authenticator.
111121
// In browsers, must be called via a user gesture.
112-
static async get({ userVerification, mediation }: PassKeyGetOptions = {}): Promise<PassKey> {
122+
static async get({
123+
userVerification,
124+
mediation,
125+
allowCredentials = [],
126+
}: PassKeyGetOptions = {}): Promise<PassKey> {
113127
// Select any credential available with the same `RP_ID`.
114-
let credential = await navigator.credentials.get({
128+
let credential = (await navigator.credentials.get({
115129
publicKey: {
116-
allowCredentials: [],
130+
allowCredentials,
117131
challenge: random(32),
118132
rpId: RP_ID,
119133
userVerification: userVerification ?? "preferred",
120-
extensions: { prf: { eval: { first: PRF_SALT }}},
134+
extensions: { prf: { eval: { first: PRF_SALT } } },
121135
timeout: TIMEOUT,
122136
},
123137
mediation,
124-
}) as PublicKeyCredential | null;
138+
})) as PublicKeyCredential | null;
125139

126140
if (!credential) {
127141
throw new Error("common-identity: Could not create credentials.");
@@ -136,7 +150,7 @@ export class PassKey {
136150
}
137151
return new PassKey(credential);
138152
}
139-
153+
140154
private getCredentials(): PublicKeyCredential {
141155
return this.credentials;
142156
}

typescript/packages/jumble/src/contexts/AuthenticationContext.tsx

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ interface AuthenticationContextType {
1010
// The authenticated user/persona.
1111
user: Identity | void;
1212
// Call PassKey registration.
13-
passkeyRegister: (name: string, displayName: string) => Promise<void>;
13+
passkeyRegister: (name: string, displayName: string) => Promise<PassKey>;
1414
// Authenticate the user via passkey.
15-
passkeyAuthenticate: () => Promise<void>;
15+
passkeyAuthenticate: (descriptor?: PublicKeyCredentialDescriptor) => Promise<PassKey>;
1616
// Generate a passphrase for a new user
1717
passphraseRegister: () => Promise<string>;
1818
// Authenticate via passphrase.
@@ -21,7 +21,7 @@ interface AuthenticationContextType {
2121
clearAuthentication: () => Promise<void>;
2222
// Internal: Root key.
2323
root: Identity | void;
24-
// Internal: Persistent storage for keys.
24+
// Internal: Persistent storage for keys.
2525
keyStore: KeyStore | void;
2626
}
2727

@@ -36,19 +36,19 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
3636
useEffect(() => {
3737
let ignore = false;
3838
async function getKeyStoreAndRoot() {
39-
let keyStore = await KeyStore.open();
40-
let root = await keyStore.get(ROOT_KEY);
39+
const keyStore = await KeyStore.open();
40+
const root = await keyStore.get(ROOT_KEY);
4141
if (!ignore) {
4242
setKeyStore(keyStore);
4343
setRoot(root);
4444
}
4545
}
46-
getKeyStoreAndRoot();
46+
getKeyStoreAndRoot();
4747
return () => {
4848
ignore = true;
4949
setKeyStore(undefined);
5050
setRoot(undefined);
51-
}
51+
};
5252
}, []);
5353

5454
// When root changes, update `user` to the default persona
@@ -59,7 +59,7 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
5959
if (!root) {
6060
return;
6161
}
62-
let user = await root.derive(DEFAULT_PERSONA);
62+
const user = await root.derive(DEFAULT_PERSONA);
6363
if (!ignore) {
6464
setUser(user);
6565
}
@@ -68,7 +68,7 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
6868
return () => {
6969
ignore = true;
7070
setUser(undefined);
71-
}
71+
};
7272
}, [root]);
7373

7474
// This calls out to WebAuthn to register a user. The state of whether
@@ -77,44 +77,51 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
7777
//
7878
// Must be called within a user gesture.
7979
const passkeyRegister = useCallback(async (name: string, displayName: string) => {
80-
return PassKey.create(name, displayName);
80+
const credential = await PassKey.create(name, displayName);
81+
return credential;
8182
}, []);
8283

83-
8484
// This should be called when a passkey (possibly) exists for the user already,
8585
// and no root key has yet been stored (e.g. first login). Subsequent page loads
8686
// should load key from storage and not require this callback.
8787
//
8888
// Must be called within a user gesture.
89-
const passkeyAuthenticate = useCallback(async () => {
90-
if (!keyStore) {
91-
return;
92-
}
93-
let passkey = await PassKey.get();
94-
let root = await passkey.createRootKey();
95-
await keyStore.set(ROOT_KEY, root);
96-
setRoot(root);
97-
}, [keyStore]);
98-
89+
const passkeyAuthenticate = useCallback(
90+
async (key?: PublicKeyCredentialDescriptor) => {
91+
if (!keyStore) {
92+
throw new Error("Key store not initialized");
93+
}
94+
// if we can, we prompt directly for the passed credential
95+
const passkey = await PassKey.get({ allowCredentials: key ? [key] : [] });
96+
const root = await passkey.createRootKey();
97+
await keyStore.set(ROOT_KEY, root);
98+
setRoot(root);
99+
return passkey;
100+
},
101+
[keyStore],
102+
);
99103

100104
const passphraseRegister = useCallback(async () => {
101105
// Don't store the root identity here. Return only the
102106
// mnemonic so that the UI can present guidance on handling
103107
// the private key. The root will be derived from the mnemonic
104108
// on authentication.
105-
let [_, mnemonic] = await Identity.generateMnemonic();
109+
const [, mnemonic] = await Identity.generateMnemonic();
106110
return mnemonic;
107111
}, []);
108112

109-
const passphraseAuthenticate = useCallback(async (mnemonic: string) => {
110-
if (!keyStore) {
111-
return;
112-
}
113-
let root = await Identity.fromMnemonic(mnemonic);
114-
await keyStore.set(ROOT_KEY, root);
115-
setRoot(root);
116-
}, [keyStore]);
117-
113+
const passphraseAuthenticate = useCallback(
114+
async (mnemonic: string) => {
115+
if (!keyStore) {
116+
throw new Error("Key store not initialized");
117+
}
118+
const root = await Identity.fromMnemonic(mnemonic);
119+
await keyStore.set(ROOT_KEY, root);
120+
setRoot(root);
121+
},
122+
[keyStore],
123+
);
124+
118125
const clearAuthentication = useCallback(async () => {
119126
if (!keyStore) {
120127
return;
@@ -125,16 +132,18 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
125132
}, [keyStore]);
126133

127134
return (
128-
<AuthenticationContext.Provider value={{
129-
user,
130-
passkeyAuthenticate,
131-
passkeyRegister,
132-
passphraseAuthenticate,
133-
passphraseRegister,
134-
clearAuthentication,
135-
root,
136-
keyStore,
137-
}}>
135+
<AuthenticationContext.Provider
136+
value={{
137+
user,
138+
passkeyAuthenticate,
139+
passkeyRegister,
140+
passphraseAuthenticate,
141+
passphraseRegister,
142+
clearAuthentication,
143+
root,
144+
keyStore,
145+
}}
146+
>
138147
{children}
139148
</AuthenticationContext.Provider>
140149
);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export const AUTH_METHOD_PASSKEY = "passkey" as const;
2+
export const AUTH_METHOD_PASSPHRASE = "passphrase" as const;
3+
4+
export type AuthMethod = typeof AUTH_METHOD_PASSKEY | typeof AUTH_METHOD_PASSPHRASE;
5+
6+
export interface StoredCredential {
7+
id: string;
8+
method: AuthMethod;
9+
}
10+
11+
export function getStoredCredential(): StoredCredential | null {
12+
const stored = localStorage.getItem("storedCredential");
13+
return stored ? JSON.parse(stored) : null;
14+
}
15+
16+
export function saveCredential(credential: StoredCredential): void {
17+
localStorage.setItem("storedCredential", JSON.stringify(credential));
18+
}
19+
20+
export function clearStoredCredential(): void {
21+
localStorage.removeItem("storedCredential");
22+
}
23+
24+
export function createPasskeyCredential(id: string): StoredCredential {
25+
return {
26+
id,
27+
method: "passkey",
28+
};
29+
}
30+
31+
export function createPassphraseCredential(): StoredCredential {
32+
return {
33+
id: crypto.randomUUID(),
34+
method: "passphrase",
35+
};
36+
}
37+
38+
export function getPublicKeyCredentialDescriptor(
39+
storedCredential: StoredCredential | null,
40+
): PublicKeyCredentialDescriptor | undefined {
41+
if (storedCredential?.method === "passkey") {
42+
return {
43+
id: Uint8Array.from(atob(storedCredential.id), (c) => c.charCodeAt(0)),
44+
type: "public-key" as PublicKeyCredentialType,
45+
};
46+
}
47+
return undefined;
48+
}

0 commit comments

Comments
 (0)