-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
45 changed files
with
2,276 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,47 @@ | ||
import {Key} from '../jwk' | ||
import {CryptoKey} from '#/oauth-client-temp/client/key' | ||
import {Key} from '#/oauth-client-temp/jwk' | ||
|
||
// TODO this might not be necessary with this setup, we will see | ||
|
||
export type DigestAlgorithm = { | ||
name: 'sha256' | 'sha384' | 'sha512' | ||
} | ||
|
||
export type {Key} | ||
export class CryptoImplementation { | ||
public async createKey(algs: string[]): Promise<Key> { | ||
return CryptoKey.generate(undefined, algs) | ||
} | ||
|
||
getRandomValues(byteLength: number): Uint8Array { | ||
const bytes = new Uint8Array(byteLength) | ||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
return crypto.getRandomValues(bytes) | ||
} | ||
|
||
async digest( | ||
bytes: Uint8Array, | ||
algorithm: DigestAlgorithm, | ||
): Promise<Uint8Array> { | ||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
const buffer = await this.crypto.subtle.digest( | ||
digestAlgorithmToSubtle(algorithm), | ||
bytes, | ||
) | ||
return new Uint8Array(buffer) | ||
} | ||
} | ||
|
||
export interface CryptoImplementation { | ||
createKey(algs: ) | ||
// TODO OAUTH types | ||
// @ts-ignore | ||
function digestAlgorithmToSubtle({name}: DigestAlgorithm): AlgorithmIdentifier { | ||
switch (name) { | ||
case 'sha256': | ||
case 'sha384': | ||
case 'sha512': | ||
return `SHA-${name.slice(-3)}` | ||
default: | ||
throw new Error(`Unknown hash algorithm ${name}`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
import {b64uEncode} from '#/oauth-client-temp/b64' | ||
import { | ||
JwtHeader, | ||
JwtPayload, | ||
Key, | ||
unsafeDecodeJwt, | ||
} from '#/oauth-client-temp/jwk' | ||
import {CryptoImplementation, DigestAlgorithm} from './crypto-implementation' | ||
|
||
export class CryptoWrapper { | ||
constructor(protected implementation: CryptoImplementation) {} | ||
|
||
public async generateKey(algs: string[]): Promise<Key> { | ||
return this.implementation.createKey(algs) | ||
} | ||
|
||
public async sha256(text: string): Promise<string> { | ||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
const bytes = new TextEncoder().encode(text) | ||
const digest = await this.implementation.digest(bytes, {name: 'sha256'}) | ||
return b64uEncode(digest) | ||
} | ||
|
||
public async generateNonce(length = 16): Promise<string> { | ||
const bytes = await this.implementation.getRandomValues(length) | ||
return b64uEncode(bytes) | ||
} | ||
|
||
public async validateIdTokenClaims( | ||
token: string, | ||
state: string, | ||
nonce: string, | ||
code?: string, | ||
accessToken?: string, | ||
): Promise<{ | ||
header: JwtHeader | ||
payload: JwtPayload | ||
}> { | ||
// It's fine to use unsafeDecodeJwt here because the token was received from | ||
// the server's token endpoint. The following checks are to ensure that the | ||
// oauth flow was indeed initiated by the client. | ||
const {header, payload} = unsafeDecodeJwt(token) | ||
if (!payload.nonce || payload.nonce !== nonce) { | ||
throw new TypeError('Nonce mismatch') | ||
} | ||
if (payload.c_hash) { | ||
await this.validateHashClaim(payload.c_hash, code, header) | ||
} | ||
if (payload.s_hash) { | ||
await this.validateHashClaim(payload.s_hash, state, header) | ||
} | ||
if (payload.at_hash) { | ||
await this.validateHashClaim(payload.at_hash, accessToken, header) | ||
} | ||
return {header, payload} | ||
} | ||
|
||
private async validateHashClaim( | ||
claim: unknown, | ||
source: unknown, | ||
header: {alg: string; crv?: string}, | ||
): Promise<void> { | ||
if (typeof claim !== 'string' || !claim) { | ||
throw new TypeError(`string "_hash" claim expected`) | ||
} | ||
if (typeof source !== 'string' || !source) { | ||
throw new TypeError(`string value expected`) | ||
} | ||
const expected = await this.generateHashClaim(source, header) | ||
if (expected !== claim) { | ||
throw new TypeError(`"_hash" does not match`) | ||
} | ||
} | ||
|
||
protected async generateHashClaim( | ||
source: string, | ||
header: {alg: string; crv?: string}, | ||
) { | ||
const algo = getHashAlgo(header) | ||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
const bytes = new TextEncoder().encode(source) | ||
const digest = await this.implementation.digest(bytes, algo) | ||
return b64uEncode(digest.slice(0, digest.length / 2)) | ||
} | ||
|
||
public async generatePKCE(byteLength?: number) { | ||
const verifier = await this.generateVerifier(byteLength) | ||
return { | ||
verifier, | ||
challenge: await this.sha256(verifier), | ||
method: 'S256', | ||
} | ||
} | ||
|
||
// TODO OAUTH types | ||
public async calculateJwkThumbprint(jwk: any) { | ||
const components = extractJktComponents(jwk) | ||
const data = JSON.stringify(components) | ||
return this.sha256(data) | ||
} | ||
|
||
/** | ||
* @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1} | ||
* @note It is RECOMMENDED that the output of a suitable random number generator | ||
* be used to create a 32-octet sequence. The octet sequence is then | ||
* base64url-encoded to produce a 43-octet URL safe string to use as the code | ||
* verifier. | ||
*/ | ||
protected async generateVerifier(byteLength = 32) { | ||
if (byteLength < 32 || byteLength > 96) { | ||
throw new TypeError('Invalid code_verifier length') | ||
} | ||
const bytes = await this.implementation.getRandomValues(byteLength) | ||
return b64uEncode(bytes) | ||
} | ||
} | ||
|
||
function getHashAlgo(header: {alg: string; crv?: string}): DigestAlgorithm { | ||
switch (header.alg) { | ||
case 'HS256': | ||
case 'RS256': | ||
case 'PS256': | ||
case 'ES256': | ||
case 'ES256K': | ||
return {name: 'sha256'} | ||
case 'HS384': | ||
case 'RS384': | ||
case 'PS384': | ||
case 'ES384': | ||
return {name: 'sha384'} | ||
case 'HS512': | ||
case 'RS512': | ||
case 'PS512': | ||
case 'ES512': | ||
return {name: 'sha512'} | ||
case 'EdDSA': | ||
switch (header.crv) { | ||
case 'Ed25519': | ||
return {name: 'sha512'} | ||
default: | ||
throw new TypeError('unrecognized or invalid EdDSA curve provided') | ||
} | ||
default: | ||
throw new TypeError('unrecognized or invalid JWS algorithm provided') | ||
} | ||
} | ||
|
||
// TODO OAUTH types | ||
function extractJktComponents(jwk: {[x: string]: any; kty: any}) { | ||
// TODO OAUTH types | ||
const get = (field: string) => { | ||
const value = jwk[field] | ||
if (typeof value !== 'string' || !value) { | ||
throw new TypeError(`"${field}" Parameter missing or invalid`) | ||
} | ||
return value | ||
} | ||
|
||
switch (jwk.kty) { | ||
case 'EC': | ||
return {crv: get('crv'), kty: get('kty'), x: get('x'), y: get('y')} | ||
case 'OKP': | ||
return {crv: get('crv'), kty: get('kty'), x: get('x')} | ||
case 'RSA': | ||
return {e: get('e'), kty: get('kty'), n: get('n')} | ||
case 'oct': | ||
return {k: get('k'), kty: get('kty')} | ||
default: | ||
throw new TypeError('"kty" (Key Type) Parameter missing or unsupported') | ||
} | ||
} |
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import { | ||
fromSubtleAlgorithm, | ||
generateKeypair, | ||
isSignatureKeyPair, | ||
} from '#/oauth-client-temp/client/util' | ||
import {Jwk, jwkSchema} from '#/oauth-client-temp/jwk' | ||
import {JoseKey} from '#/oauth-client-temp/jwk-jose' | ||
|
||
export class CryptoKey extends JoseKey { | ||
// static async fromIndexedDB(kid: string, allowedAlgos: string[] = ['ES384']) { | ||
// const cryptoKeyPair = await loadCryptoKeyPair(kid, allowedAlgos) | ||
// return this.fromKeypair(kid, cryptoKeyPair) | ||
// } | ||
|
||
static async generate( | ||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
kid: string = crypto.randomUUID(), | ||
allowedAlgos: string[] = ['ES384'], | ||
exportable = false, | ||
) { | ||
const cryptoKeyPair = await generateKeypair(allowedAlgos, exportable) | ||
return this.fromKeypair(kid, cryptoKeyPair) | ||
} | ||
|
||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
static async fromKeypair(kid: string, cryptoKeyPair: CryptoKeyPair) { | ||
if (!isSignatureKeyPair(cryptoKeyPair)) { | ||
throw new TypeError('CryptoKeyPair must be compatible with sign/verify') | ||
} | ||
|
||
// https://datatracker.ietf.org/doc/html/rfc7517 | ||
// > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...] | ||
// > Applications should specify which of these members they use. | ||
|
||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
const {key_ops: _, ...jwk} = await crypto.subtle.exportKey( | ||
'jwk', | ||
cryptoKeyPair.privateKey.extractable | ||
? cryptoKeyPair.privateKey | ||
: cryptoKeyPair.publicKey, | ||
) | ||
|
||
const use = jwk.use ?? 'sig' | ||
const alg = | ||
jwk.alg ?? fromSubtleAlgorithm(cryptoKeyPair.privateKey.algorithm) | ||
|
||
if (use !== 'sig') { | ||
throw new TypeError('Unsupported JWK use') | ||
} | ||
|
||
return new CryptoKey( | ||
jwkSchema.parse({...jwk, use, kid, alg}), | ||
cryptoKeyPair, | ||
) | ||
} | ||
|
||
// TODO REVIEW POLYFILL | ||
// @ts-ignore Polyfilled | ||
constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) { | ||
super(jwk) | ||
} | ||
|
||
get isPrivate() { | ||
return true | ||
} | ||
|
||
get privateJwk(): Jwk | undefined { | ||
if (super.isPrivate) return this.jwk | ||
throw new Error('Private Webcrypto Key not exportable') | ||
} | ||
|
||
protected async getKey() { | ||
return this.cryptoKeyPair.privateKey | ||
} | ||
} |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
export class OAuthCallbackError extends Error { | ||
static from(err: unknown, params: URLSearchParams, state?: string) { | ||
if (err instanceof OAuthCallbackError) return err | ||
const message = err instanceof Error ? err.message : undefined | ||
return new OAuthCallbackError(params, message, state, err) | ||
} | ||
|
||
constructor( | ||
public readonly params: URLSearchParams, | ||
message = params.get('error_description') || 'OAuth callback error', | ||
public readonly state?: string, | ||
cause?: unknown, | ||
) { | ||
super(message, { cause }) | ||
} | ||
} |
Oops, something went wrong.