import { RelayDpopAccessTokenScope, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, RelayMobileClientId, RelayMobileRegistrationScope, RelayWebClientId, type RelayPublicClientId, type RelayEnvironmentLinkChallengeRequest, } from "@t3tools/contracts/relay"; import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { normalizeRelayIssuer, signRelayJwt, verifyRelayJwt, type RelayJwtError, } from "@t3tools/shared/relayJwt"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as RelayConfiguration from "../Config.ts"; const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; const LINK_CHALLENGE_KIND = "environment_link_challenge"; export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; const LinkChallengeClaims = Schema.Struct({ kind: Schema.Literal(LINK_CHALLENGE_KIND), iss: Schema.String, aud: Schema.String, sub: Schema.String, jti: Schema.String, iat: Schema.Int, exp: Schema.Int, notificationsEnabled: Schema.Boolean, liveActivitiesEnabled: Schema.Boolean, managedTunnelsEnabled: Schema.Boolean, }); export type LinkChallengeClaims = typeof LinkChallengeClaims.Type; const RelayDpopAccessTokenClaims = Schema.Struct({ iss: Schema.String, aud: Schema.String, sub: Schema.String, jti: Schema.String, iat: Schema.Int, exp: Schema.Int, client_id: Schema.Literals([RelayMobileClientId, RelayWebClientId]), scope: Schema.String, cnf: Schema.Struct({ jkt: Schema.String }), }); export type RelayDpopAccessTokenClaims = Omit & { readonly scope: ReadonlyArray; }; const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims); const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims); const allowedScopesByClientId: Record< RelayPublicClientId, ReadonlySet > = { [RelayMobileClientId]: new Set([ RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, RelayMobileRegistrationScope, ]), [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), }; function relayJwtVerificationFailureReason(error: RelayJwtError): string { const cause = error.cause; if (typeof cause === "object" && cause !== null && "code" in cause) { const code = (cause as { readonly code?: unknown }).code; if (typeof code === "string" && code.length > 0) { return code; } } return cause instanceof Error && cause.name ? cause.name : "unknown"; } function resolveDpopAccessTokenScopes(input: { readonly clientId: RelayPublicClientId; readonly scope: string; }): ReadonlyArray | null { return parseAllowedOAuthScope({ value: input.scope, allowedScopes: allowedScopesByClientId[input.clientId], }); } export class RelayTokens extends Context.Service< RelayTokens, { readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; readonly issueLinkChallenge: (input: { readonly userId: string; readonly request: RelayEnvironmentLinkChallengeRequest; readonly jti: string; readonly issuedAtEpochSeconds: number; readonly expiresAtEpochSeconds: number; }) => Effect.Effect; readonly verifyLinkChallenge: (input: { readonly token: string; readonly userId: string; readonly request: RelayEnvironmentLinkChallengeRequest; readonly nowEpochSeconds: number; }) => Effect.Effect; readonly issueDpopAccessToken: (input: { readonly userId: string; readonly proofKeyThumbprint: string; readonly jti: string; readonly issuedAtEpochSeconds: number; readonly expiresAtEpochSeconds: number; readonly clientId: RelayPublicClientId; readonly scopes: ReadonlyArray; }) => Effect.Effect; readonly verifyDpopAccessToken: (input: { readonly token: string; readonly nowEpochSeconds: number; }) => Effect.Effect; } >()("t3code-relay/auth/RelayTokens") {} const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const issuer = normalizeRelayIssuer(config.relayIssuer); const issueLinkChallenge: RelayTokens["Service"]["issueLinkChallenge"] = Effect.fn( "relay.tokens.issue_link_challenge", )(function* (input) { return yield* signRelayJwt({ privateKey: Redacted.value(config.cloudMintPrivateKey), typ: LINK_CHALLENGE_TYP, payload: { kind: LINK_CHALLENGE_KIND, iss: issuer, aud: issuer, sub: input.userId, jti: input.jti, iat: input.issuedAtEpochSeconds, exp: input.expiresAtEpochSeconds, ...input.request, }, }); }); const verifyLinkChallenge: RelayTokens["Service"]["verifyLinkChallenge"] = Effect.fn( "relay.tokens.verify_link_challenge", )((input) => verifyRelayJwt({ publicKey: config.cloudMintPublicKey, token: input.token, typ: LINK_CHALLENGE_TYP, issuer, audience: issuer, nowEpochSeconds: input.nowEpochSeconds, }).pipe( Effect.flatMap(decodeLinkChallengeClaims), Effect.map((claims) => { if ( claims.sub !== input.userId || (input.request.notificationsEnabled && claims.notificationsEnabled !== true) || (input.request.liveActivitiesEnabled && claims.liveActivitiesEnabled !== true) || (input.request.managedTunnelsEnabled && claims.managedTunnelsEnabled !== true) ) { return null; } return claims; }), Effect.orElseSucceed(() => null), ), ); const issueDpopAccessToken: RelayTokens["Service"]["issueDpopAccessToken"] = Effect.fn( "relay.tokens.issue_dpop_access_token", )(function* (input) { return yield* signRelayJwt({ privateKey: Redacted.value(config.cloudMintPrivateKey), typ: ACCESS_TOKEN_TYP, payload: { iss: issuer, aud: issuer, sub: input.userId, jti: input.jti, iat: input.issuedAtEpochSeconds, exp: input.expiresAtEpochSeconds, client_id: input.clientId, scope: encodeOAuthScope(input.scopes), cnf: { jkt: input.proofKeyThumbprint }, }, }); }); const verifyDpopAccessToken: RelayTokens["Service"]["verifyDpopAccessToken"] = Effect.fn( "relay.tokens.verify_dpop_access_token", )((input) => verifyRelayJwt({ publicKey: config.cloudMintPublicKey, token: input.token, typ: ACCESS_TOKEN_TYP, issuer, audience: issuer, nowEpochSeconds: input.nowEpochSeconds, maxTokenAge: RELAY_DPOP_ACCESS_TOKEN_TTL, }).pipe( Effect.tapError((error) => Effect.annotateCurrentSpan( "relay.tokens.verification_failure", relayJwtVerificationFailureReason(error), ), ), Effect.flatMap(decodeDpopAccessTokenClaims), Effect.map((claims): RelayDpopAccessTokenClaims | null => { const scopes = resolveDpopAccessTokenScopes({ clientId: claims.client_id, scope: claims.scope, }); return scopes === null ? null : { ...claims, scope: scopes }; }), Effect.orElseSucceed(() => null), ), ); return RelayTokens.of({ resolveDpopAccessTokenScopes, issueLinkChallenge, verifyLinkChallenge, issueDpopAccessToken, verifyDpopAccessToken, }); }); export const layer = Layer.effect(RelayTokens, make);