| 1 | import { |
| 2 | RelayDpopAccessTokenScope, |
| 3 | RelayEnvironmentConnectScope, |
| 4 | RelayEnvironmentStatusScope, |
| 5 | RelayMobileClientId, |
| 6 | RelayMobileRegistrationScope, |
| 7 | RelayWebClientId, |
| 8 | type RelayPublicClientId, |
| 9 | type RelayEnvironmentLinkChallengeRequest, |
| 10 | } from "@t3tools/contracts/relay"; |
| 11 | import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; |
| 12 | import { |
| 13 | normalizeRelayIssuer, |
| 14 | RelayJwtError, |
| 15 | signRelayJwt, |
| 16 | verifyRelayJwt, |
| 17 | } from "@t3tools/shared/relayJwt"; |
| 18 | import * as Context from "effect/Context"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Layer from "effect/Layer"; |
| 21 | import * as Redacted from "effect/Redacted"; |
| 22 | import * as Schema from "effect/Schema"; |
| 23 | |
| 24 | import * as RelayConfiguration from "../Config.ts"; |
| 25 | |
| 26 | const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; |
| 27 | const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; |
| 28 | const LINK_CHALLENGE_KIND = "environment_link_challenge"; |
| 29 | export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; |
| 30 | |
| 31 | const LinkChallengeClaims = Schema.Struct({ |
| 32 | kind: Schema.Literal(LINK_CHALLENGE_KIND), |
| 33 | iss: Schema.String, |
| 34 | aud: Schema.String, |
| 35 | sub: Schema.String, |
| 36 | jti: Schema.String, |
| 37 | iat: Schema.Int, |
| 38 | exp: Schema.Int, |
| 39 | notificationsEnabled: Schema.Boolean, |
| 40 | liveActivitiesEnabled: Schema.Boolean, |
| 41 | managedTunnelsEnabled: Schema.Boolean, |
| 42 | }); |
| 43 | export type LinkChallengeClaims = typeof LinkChallengeClaims.Type; |
| 44 | |
| 45 | const RelayDpopAccessTokenClaims = Schema.Struct({ |
| 46 | iss: Schema.String, |
| 47 | aud: Schema.String, |
| 48 | sub: Schema.String, |
| 49 | jti: Schema.String, |
| 50 | iat: Schema.Int, |
| 51 | exp: Schema.Int, |
| 52 | client_id: Schema.Literals([RelayMobileClientId, RelayWebClientId]), |
| 53 | scope: Schema.String, |
| 54 | cnf: Schema.Struct({ jkt: Schema.String }), |
| 55 | }); |
| 56 | export type RelayDpopAccessTokenClaims = Omit<typeof RelayDpopAccessTokenClaims.Type, "scope"> & { |
| 57 | readonly scope: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 58 | }; |
| 59 | |
| 60 | const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims); |
| 61 | const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims); |
| 62 | |
| 63 | const allowedScopesByClientId: Record< |
| 64 | RelayPublicClientId, |
| 65 | ReadonlySet<RelayDpopAccessTokenScope> |
| 66 | > = { |
| 67 | [RelayMobileClientId]: new Set([ |
| 68 | RelayEnvironmentConnectScope, |
| 69 | RelayEnvironmentStatusScope, |
| 70 | RelayMobileRegistrationScope, |
| 71 | ]), |
| 72 | [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), |
| 73 | }; |
| 74 | |
| 75 | function resolveDpopAccessTokenScopes(input: { |
| 76 | readonly clientId: RelayPublicClientId; |
| 77 | readonly scope: string; |
| 78 | }): ReadonlyArray<RelayDpopAccessTokenScope> | null { |
| 79 | return parseAllowedOAuthScope({ |
| 80 | value: input.scope, |
| 81 | allowedScopes: allowedScopesByClientId[input.clientId], |
| 82 | }); |
| 83 | } |
| 84 | |
| 85 | export class RelayTokens extends Context.Service< |
| 86 | RelayTokens, |
| 87 | { |
| 88 | readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; |
| 89 | readonly issueLinkChallenge: (input: { |
| 90 | readonly userId: string; |
| 91 | readonly request: RelayEnvironmentLinkChallengeRequest; |
| 92 | readonly jti: string; |
| 93 | readonly issuedAtEpochSeconds: number; |
| 94 | readonly expiresAtEpochSeconds: number; |
| 95 | }) => Effect.Effect<string, RelayJwtError>; |
| 96 | readonly verifyLinkChallenge: (input: { |
| 97 | readonly token: string; |
| 98 | readonly userId: string; |
| 99 | readonly request: RelayEnvironmentLinkChallengeRequest; |
| 100 | readonly nowEpochSeconds: number; |
| 101 | }) => Effect.Effect<LinkChallengeClaims | null>; |
| 102 | readonly issueDpopAccessToken: (input: { |
| 103 | readonly userId: string; |
| 104 | readonly proofKeyThumbprint: string; |
| 105 | readonly jti: string; |
| 106 | readonly issuedAtEpochSeconds: number; |
| 107 | readonly expiresAtEpochSeconds: number; |
| 108 | readonly clientId: RelayPublicClientId; |
| 109 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 110 | }) => Effect.Effect<string, RelayJwtError>; |
| 111 | readonly verifyDpopAccessToken: (input: { |
| 112 | readonly token: string; |
| 113 | readonly nowEpochSeconds: number; |
| 114 | }) => Effect.Effect<RelayDpopAccessTokenClaims | null>; |
| 115 | } |
| 116 | >()("t3code-relay/auth/RelayTokens") {} |
| 117 | |
| 118 | const make = Effect.gen(function* () { |
| 119 | const config = yield* RelayConfiguration.RelayConfiguration; |
| 120 | const issuer = normalizeRelayIssuer(config.relayIssuer); |
| 121 | |
| 122 | const issueLinkChallenge: RelayTokens["Service"]["issueLinkChallenge"] = Effect.fn( |
| 123 | "relay.tokens.issue_link_challenge", |
| 124 | )(function* (input) { |
| 125 | return yield* signRelayJwt({ |
| 126 | privateKey: Redacted.value(config.cloudMintPrivateKey), |
| 127 | typ: LINK_CHALLENGE_TYP, |
| 128 | payload: { |
| 129 | kind: LINK_CHALLENGE_KIND, |
| 130 | iss: issuer, |
| 131 | aud: issuer, |
| 132 | sub: input.userId, |
| 133 | jti: input.jti, |
| 134 | iat: input.issuedAtEpochSeconds, |
| 135 | exp: input.expiresAtEpochSeconds, |
| 136 | ...input.request, |
| 137 | }, |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | const verifyLinkChallenge: RelayTokens["Service"]["verifyLinkChallenge"] = Effect.fn( |
| 142 | "relay.tokens.verify_link_challenge", |
| 143 | )((input) => |
| 144 | verifyRelayJwt({ |
| 145 | publicKey: config.cloudMintPublicKey, |
| 146 | token: input.token, |
| 147 | typ: LINK_CHALLENGE_TYP, |
| 148 | issuer, |
| 149 | audience: issuer, |
| 150 | nowEpochSeconds: input.nowEpochSeconds, |
| 151 | }).pipe( |
| 152 | Effect.flatMap(decodeLinkChallengeClaims), |
| 153 | Effect.map((claims) => { |
| 154 | if ( |
| 155 | claims.sub !== input.userId || |
| 156 | (input.request.notificationsEnabled && claims.notificationsEnabled !== true) || |
| 157 | (input.request.liveActivitiesEnabled && claims.liveActivitiesEnabled !== true) || |
| 158 | (input.request.managedTunnelsEnabled && claims.managedTunnelsEnabled !== true) |
| 159 | ) { |
| 160 | return null; |
| 161 | } |
| 162 | return claims; |
| 163 | }), |
| 164 | Effect.orElseSucceed(() => null), |
| 165 | ), |
| 166 | ); |
| 167 | |
| 168 | const issueDpopAccessToken: RelayTokens["Service"]["issueDpopAccessToken"] = Effect.fn( |
| 169 | "relay.tokens.issue_dpop_access_token", |
| 170 | )(function* (input) { |
| 171 | return yield* signRelayJwt({ |
| 172 | privateKey: Redacted.value(config.cloudMintPrivateKey), |
| 173 | typ: ACCESS_TOKEN_TYP, |
| 174 | payload: { |
| 175 | iss: issuer, |
| 176 | aud: issuer, |
| 177 | sub: input.userId, |
| 178 | jti: input.jti, |
| 179 | iat: input.issuedAtEpochSeconds, |
| 180 | exp: input.expiresAtEpochSeconds, |
| 181 | client_id: input.clientId, |
| 182 | scope: encodeOAuthScope(input.scopes), |
| 183 | cnf: { jkt: input.proofKeyThumbprint }, |
| 184 | }, |
| 185 | }); |
| 186 | }); |
| 187 | |
| 188 | const verifyDpopAccessToken: RelayTokens["Service"]["verifyDpopAccessToken"] = Effect.fn( |
| 189 | "relay.tokens.verify_dpop_access_token", |
| 190 | )((input) => |
| 191 | verifyRelayJwt({ |
| 192 | publicKey: config.cloudMintPublicKey, |
| 193 | token: input.token, |
| 194 | typ: ACCESS_TOKEN_TYP, |
| 195 | issuer, |
| 196 | audience: issuer, |
| 197 | nowEpochSeconds: input.nowEpochSeconds, |
| 198 | maxTokenAge: RELAY_DPOP_ACCESS_TOKEN_TTL, |
| 199 | }).pipe( |
| 200 | Effect.tapError((error) => |
| 201 | Effect.annotateCurrentSpan( |
| 202 | "relay.tokens.verification_failure", |
| 203 | RelayJwtError.diagnosticCode(error), |
| 204 | ), |
| 205 | ), |
| 206 | Effect.flatMap(decodeDpopAccessTokenClaims), |
| 207 | Effect.map((claims): RelayDpopAccessTokenClaims | null => { |
| 208 | const scopes = resolveDpopAccessTokenScopes({ |
| 209 | clientId: claims.client_id, |
| 210 | scope: claims.scope, |
| 211 | }); |
| 212 | return scopes === null ? null : { ...claims, scope: scopes }; |
| 213 | }), |
| 214 | Effect.orElseSucceed(() => null), |
| 215 | ), |
| 216 | ); |
| 217 | |
| 218 | return RelayTokens.of({ |
| 219 | resolveDpopAccessTokenScopes, |
| 220 | issueLinkChallenge, |
| 221 | verifyLinkChallenge, |
| 222 | issueDpopAccessToken, |
| 223 | verifyDpopAccessToken, |
| 224 | }); |
| 225 | }); |
| 226 | |
| 227 | export const layer = Layer.effect(RelayTokens, make); |