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 {
13 normalizeRelayIssuer,
14 RelayJwtError,
15 signRelayJwt,
16 verifyRelayJwt,
17} from "@t3tools/shared/relayJwt";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Redacted from "effect/Redacted";
22import * as Schema from "effect/Schema";
23
24import * as RelayConfiguration from "../Config.ts";
25
26const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt";
27const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt";
28const LINK_CHALLENGE_KIND = "environment_link_challenge";
29export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes";
30
31const 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});
43export type LinkChallengeClaims = typeof LinkChallengeClaims.Type;
44
45const 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});
56export type RelayDpopAccessTokenClaims = Omit<typeof RelayDpopAccessTokenClaims.Type, "scope"> & {
57 readonly scope: ReadonlyArray<RelayDpopAccessTokenScope>;
58};
59
60const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims);
61const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims);
62
63const 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
75function 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
85export 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
118const 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
227export const layer = Layer.effect(RelayTokens, make);