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