| author | |
| committer | |
| log | e9c3d950f023130791d8ac5fe7aa0992e3430738 |
| tree | e6886f00006c86fce8d32ccba2d14adc3aa9264c |
| parent | bfec2387b8102975c84690f99be0f5f834fd0cbe |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>28 files changed, 812 insertions(+), 91 deletions(-)
apps/mobile/src/connection/platform.ts-2| ... | ... | @@ -10,7 +10,6 @@ import { |
| 10 | 10 | Wakeups, |
| 11 | 11 | } from "@t3tools/client-runtime/connection"; |
| 12 | 12 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| 13 | import { AuthStandardClientScopes } from "@t3tools/contracts"; | |
| 14 | 13 | import * as Context from "effect/Context"; |
| 15 | 14 | import * as Effect from "effect/Effect"; |
| 16 | 15 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext( |
| 216 | 215 | ClientCapabilities.ClientPresentation, |
| 217 | 216 | ClientCapabilities.ClientPresentation.of({ |
| 218 | 217 | metadata: authClientMetadata(Constants.expoConfig?.version), |
| 219 | scopes: AuthStandardClientScopes, | |
| 220 | 218 | }), |
| 221 | 219 | ), |
| 222 | 220 | Context.add( |
apps/server/src/auth/EnvironmentAuth.test.ts+52-3| ... | ... | @@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 353 | 353 | }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), |
| 354 | 354 | ); |
| 355 | 355 | |
| 356 | it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => | |
| 356 | it.effect("preserves pairing grants after rejecting scopes they do not grant", () => | |
| 357 | 357 | Effect.gen(function* () { |
| 358 | 358 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 359 | 359 | const pairingCredential = yield* serverAuth.issuePairingCredential(); |
| ... | ... | @@ -367,10 +367,40 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 367 | 367 | .pipe(Effect.flip); |
| 368 | 368 | |
| 369 | 369 | expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); |
| 370 | expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).toContain( | |
| 371 | pairingCredential.id, | |
| 372 | ); | |
| 373 | expect(yield* serverAuth.listSessions()).toEqual([]); | |
| 374 | ||
| 375 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 376 | pairingCredential.credential, | |
| 377 | ["orchestration:read"], | |
| 378 | requestMetadata, | |
| 379 | ); | |
| 380 | const session = yield* serverAuth.authenticateHttpRequest( | |
| 381 | makeBearerRequest(token.access_token), | |
| 382 | ); | |
| 383 | ||
| 384 | expect(token.scope).toBe("orchestration:read"); | |
| 385 | expect(session.scopes).toEqual(["orchestration:read"]); | |
| 386 | expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).not.toContain( | |
| 387 | pairingCredential.id, | |
| 388 | ); | |
| 389 | const reused = yield* serverAuth | |
| 390 | .exchangeBootstrapCredentialForAccessToken( | |
| 391 | pairingCredential.credential, | |
| 392 | ["orchestration:read"], | |
| 393 | requestMetadata, | |
| 394 | ) | |
| 395 | .pipe(Effect.flip); | |
| 396 | expect(reused._tag).toBe("ServerAuthInvalidCredentialError"); | |
| 370 | 397 | }).pipe(Effect.provide(layerEnvironmentAuth())), |
| 371 | 398 | ); |
| 372 | 399 | |
| 373 | it.effect("inherits a constrained pairing grant when token exchange omits scope", () => | |
| 400 | it.effect.each([ | |
| 401 | { label: "omits scope", requestedScopes: undefined }, | |
| 402 | { label: "requests no scopes", requestedScopes: [] }, | |
| 403 | ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) => | |
| 374 | 404 | Effect.gen(function* () { |
| 375 | 405 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 376 | 406 | const pairingCredential = yield* serverAuth.issuePairingCredential({ |
| ... | ... | @@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 379 | 409 | |
| 380 | 410 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( |
| 381 | 411 | pairingCredential.credential, |
| 382 | undefined, | |
| 412 | requestedScopes, | |
| 383 | 413 | requestMetadata, |
| 384 | 414 | ); |
| 385 | 415 | |
| ... | ... | @@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 387 | 417 | }).pipe(Effect.provide(layerEnvironmentAuth())), |
| 388 | 418 | ); |
| 389 | 419 | |
| 420 | it.effect("narrows seeded desktop grants to the requested scopes", () => | |
| 421 | Effect.gen(function* () { | |
| 422 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 423 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 424 | "desktop-bootstrap-token", | |
| 425 | ["orchestration:read"], | |
| 426 | requestMetadata, | |
| 427 | ); | |
| 428 | const session = yield* serverAuth.authenticateHttpRequest( | |
| 429 | makeBearerRequest(token.access_token), | |
| 430 | ); | |
| 431 | ||
| 432 | expect(token.scope).toBe("orchestration:read"); | |
| 433 | expect(session.scopes).toEqual(["orchestration:read"]); | |
| 434 | }).pipe( | |
| 435 | Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })), | |
| 436 | ), | |
| 437 | ); | |
| 438 | ||
| 390 | 439 | it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => |
| 391 | 440 | Effect.gen(function* () { |
| 392 | 441 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
apps/server/src/auth/EnvironmentAuth.ts+60-45| ... | ... | @@ -466,6 +466,7 @@ export class EnvironmentAuth extends Context.Service< |
| 466 | 466 | readonly createBrowserSession: ( |
| 467 | 467 | credential: string, |
| 468 | 468 | requestMetadata: AuthClientMetadata, |
| 469 | previousSessionToken?: string, | |
| 469 | 470 | ) => Effect.Effect< |
| 470 | 471 | { |
| 471 | 472 | readonly response: AuthBrowserSessionResult; |
| ... | ... | @@ -786,12 +787,11 @@ export const make = Effect.gen(function* () { |
| 786 | 787 | Effect.withSpan("EnvironmentAuth.getSessionState"), |
| 787 | 788 | ); |
| 788 | 789 | |
| 789 | const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( | |
| 790 | credential, | |
| 791 | requestMetadata, | |
| 792 | ) => { | |
| 790 | const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn( | |
| 791 | "EnvironmentAuth.createBrowserSession", | |
| 792 | )(function* (credential, requestMetadata, previousSessionToken) { | |
| 793 | 793 | if (devAuth?.matches(credential)) { |
| 794 | return sessions.verify(credential).pipe( | |
| 794 | return yield* sessions.verify(credential).pipe( | |
| 795 | 795 | mapSessionVerificationErrors, |
| 796 | 796 | Effect.flatMap((session) => |
| 797 | 797 | DateTime.now.pipe( |
| ... | ... | @@ -814,38 +814,40 @@ export const make = Effect.gen(function* () { |
| 814 | 814 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), |
| 815 | 815 | ); |
| 816 | 816 | } |
| 817 | return bootstrapCredentials.consume(credential).pipe( | |
| 818 | Effect.mapError(toBootstrapExchangeError), | |
| 819 | Effect.flatMap((grant) => | |
| 820 | sessions | |
| 821 | .issue({ | |
| 822 | method: "browser-session-cookie", | |
| 823 | subject: grant.subject, | |
| 824 | scopes: grant.scopes, | |
| 825 | client: { | |
| 826 | ...requestMetadata, | |
| 827 | ...(grant.label ? { label: grant.label } : {}), | |
| 828 | }, | |
| 829 | }) | |
| 830 | .pipe( | |
| 831 | Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), | |
| 832 | ), | |
| 833 | ), | |
| 834 | Effect.map( | |
| 835 | (session) => | |
| 836 | ({ | |
| 837 | response: { | |
| 838 | authenticated: true, | |
| 839 | scopes: session.scopes, | |
| 840 | sessionMethod: session.method, | |
| 841 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 842 | } satisfies AuthBrowserSessionResult, | |
| 843 | sessionToken: session.token, | |
| 844 | }) satisfies BootstrapExchangeResult, | |
| 845 | ), | |
| 846 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), | |
| 847 | ); | |
| 848 | }; | |
| 817 | const previousSession = | |
| 818 | previousSessionToken === undefined | |
| 819 | ? undefined | |
| 820 | : yield* sessions.verify(previousSessionToken).pipe( | |
| 821 | Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void), | |
| 822 | Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })), | |
| 823 | ); | |
| 824 | const grant = yield* bootstrapCredentials | |
| 825 | .consume(credential) | |
| 826 | .pipe(Effect.mapError(toBootstrapExchangeError)); | |
| 827 | const session = yield* sessions | |
| 828 | .issue({ | |
| 829 | method: "browser-session-cookie", | |
| 830 | subject: grant.subject, | |
| 831 | scopes: grant.scopes, | |
| 832 | ...(previousSession?.method === "browser-session-cookie" | |
| 833 | ? { replaceSessionId: previousSession.sessionId } | |
| 834 | : {}), | |
| 835 | client: { | |
| 836 | ...requestMetadata, | |
| 837 | ...(grant.label ? { label: grant.label } : {}), | |
| 838 | }, | |
| 839 | }) | |
| 840 | .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause }))); | |
| 841 | return { | |
| 842 | response: { | |
| 843 | authenticated: true, | |
| 844 | scopes: session.scopes, | |
| 845 | sessionMethod: session.method, | |
| 846 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 847 | } satisfies AuthBrowserSessionResult, | |
| 848 | sessionToken: session.token, | |
| 849 | } satisfies BootstrapExchangeResult; | |
| 850 | }); | |
| 849 | 851 | |
| 850 | 852 | type ResolvedBootstrapGrant = Pick< |
| 851 | 853 | PairingGrantStore.BootstrapGrant, |
| ... | ... | @@ -855,15 +857,24 @@ export const make = Effect.gen(function* () { |
| 855 | 857 | }; |
| 856 | 858 | const resolveBootstrapGrant = ( |
| 857 | 859 | credential: string, |
| 858 | input?: { readonly proofKeyThumbprint?: string }, | |
| 860 | input?: { | |
| 861 | readonly proofKeyThumbprint?: string; | |
| 862 | readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 863 | }, | |
| 859 | 864 | ): Effect.Effect< |
| 860 | 865 | ResolvedBootstrapGrant, |
| 861 | ServerAuthInvalidCredentialError | ServerAuthInternalError | |
| 866 | ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError | |
| 862 | 867 | > => { |
| 863 | 868 | if (!devAuth?.matches(credential)) { |
| 864 | 869 | return bootstrapCredentials |
| 865 | 870 | .consume(credential, input) |
| 866 | .pipe(Effect.mapError(toBootstrapExchangeError)); | |
| 871 | .pipe( | |
| 872 | Effect.mapError((cause) => | |
| 873 | cause._tag === "BootstrapCredentialScopeNotGrantedError" | |
| 874 | ? new ServerAuthScopeNotGrantedError({}) | |
| 875 | : toBootstrapExchangeError(cause), | |
| 876 | ), | |
| 877 | ); | |
| 867 | 878 | } |
| 868 | 879 | return sessions.verify(credential).pipe( |
| 869 | 880 | mapSessionVerificationErrors, |
| ... | ... | @@ -879,14 +890,15 @@ export const make = Effect.gen(function* () { |
| 879 | 890 | }; |
| 880 | 891 | |
| 881 | 892 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = |
| 882 | (credential, requestedScopes, requestMetadata, input) => | |
| 883 | resolveBootstrapGrant(credential, input).pipe( | |
| 893 | (credential, requestedScopesInput, requestMetadata, input) => { | |
| 894 | const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined; | |
| 895 | return resolveBootstrapGrant(credential, { | |
| 896 | ...input, | |
| 897 | ...(requestedScopes !== undefined ? { requestedScopes } : {}), | |
| 898 | }).pipe( | |
| 884 | 899 | Effect.flatMap((grant) => |
| 885 | 900 | Effect.gen(function* () { |
| 886 | 901 | const grantedScopes = requestedScopes ?? grant.scopes; |
| 887 | if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { | |
| 888 | return yield* new ServerAuthScopeNotGrantedError({}); | |
| 889 | } | |
| 890 | 902 | return yield* sessions |
| 891 | 903 | .issue({ |
| 892 | 904 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", |
| ... | ... | @@ -934,6 +946,7 @@ export const make = Effect.gen(function* () { |
| 934 | 946 | ), |
| 935 | 947 | Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), |
| 936 | 948 | ); |
| 949 | }; | |
| 937 | 950 | |
| 938 | 951 | const issuePairingCredentialForSubject = (input: { |
| 939 | 952 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| ... | ... | @@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () { |
| 1238 | 1251 | Effect.catchTags({ |
| 1239 | 1252 | ServerAuthInvalidCredentialError: () => |
| 1240 | 1253 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), |
| 1254 | ServerAuthScopeNotGrantedError: () => | |
| 1255 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), | |
| 1241 | 1256 | }), |
| 1242 | 1257 | Effect.flatMap((grant) => |
| 1243 | 1258 | grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" |
apps/server/src/auth/PairingGrantStore.test.ts+15-1| ... | ... | @@ -104,7 +104,11 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 104 | 104 | const token = yield* bootstrapCredentials.issueOneTimeToken(); |
| 105 | 105 | const results = yield* Effect.all( |
| 106 | 106 | Array.from({ length: 8 }, () => |
| 107 | Effect.result(bootstrapCredentials.consume(token.credential)), | |
| 107 | Effect.result( | |
| 108 | bootstrapCredentials.consume(token.credential, { | |
| 109 | requestedScopes: ["orchestration:read"], | |
| 110 | }), | |
| 111 | ), | |
| 108 | 112 | ), |
| 109 | 113 | { |
| 110 | 114 | concurrency: "unbounded", |
| ... | ... | @@ -128,20 +132,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 128 | 132 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 129 | 133 | const token = yield* bootstrapCredentials.issueOneTimeToken({ |
| 130 | 134 | proofKeyThumbprint: "client-proof-key-thumbprint", |
| 135 | scopes: ["orchestration:read"], | |
| 131 | 136 | }); |
| 132 | 137 | |
| 133 | 138 | const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); |
| 134 | 139 | const wrong = yield* Effect.flip( |
| 135 | 140 | bootstrapCredentials.consume(token.credential, { |
| 136 | 141 | proofKeyThumbprint: "other-proof-key-thumbprint", |
| 142 | requestedScopes: ["access:write"], | |
| 143 | }), | |
| 144 | ); | |
| 145 | const forbiddenScope = yield* Effect.flip( | |
| 146 | bootstrapCredentials.consume(token.credential, { | |
| 147 | proofKeyThumbprint: "client-proof-key-thumbprint", | |
| 148 | requestedScopes: ["access:write"], | |
| 137 | 149 | }), |
| 138 | 150 | ); |
| 139 | 151 | const consumed = yield* bootstrapCredentials.consume(token.credential, { |
| 140 | 152 | proofKeyThumbprint: "client-proof-key-thumbprint", |
| 153 | requestedScopes: ["orchestration:read"], | |
| 141 | 154 | }); |
| 142 | 155 | |
| 143 | 156 | expect(missing.message).toContain("proof key mismatch"); |
| 144 | 157 | expect(wrong.message).toContain("proof key mismatch"); |
| 158 | expect(forbiddenScope._tag).toBe("BootstrapCredentialScopeNotGrantedError"); | |
| 145 | 159 | expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); |
| 146 | 160 | }).pipe(Effect.provide(layerPairingGrantStore())), |
| 147 | 161 | ); |
apps/server/src/auth/PairingGrantStore.ts+33-1| ... | ... | @@ -69,6 +69,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedError<Unav |
| 69 | 69 | } |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedError<BootstrapCredentialScopeNotGrantedError>()( | |
| 73 | "BootstrapCredentialScopeNotGrantedError", | |
| 74 | {}, | |
| 75 | ) { | |
| 76 | override get message(): string { | |
| 77 | return "The requested authentication scope was not granted."; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 72 | 81 | export const BootstrapCredentialInvalidError = Schema.Union([ |
| 73 | 82 | UnknownBootstrapCredentialError, |
| 74 | 83 | ExpiredBootstrapCredentialError, |
| ... | ... | @@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI |
| 174 | 183 | export const BootstrapCredentialError = Schema.Union([ |
| 175 | 184 | BootstrapCredentialInvalidError, |
| 176 | 185 | BootstrapCredentialInternalError, |
| 186 | BootstrapCredentialScopeNotGrantedError, | |
| 177 | 187 | ]); |
| 178 | 188 | export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; |
| 179 | 189 | |
| ... | ... | @@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service< |
| 220 | 230 | credential: string, |
| 221 | 231 | input?: { |
| 222 | 232 | readonly proofKeyThumbprint?: string; |
| 233 | readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 223 | 234 | }, |
| 224 | 235 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; |
| 225 | 236 | } |
| ... | ... | @@ -232,7 +243,7 @@ interface StoredBootstrapGrant extends BootstrapGrant { |
| 232 | 243 | type ConsumeResult = |
| 233 | 244 | | { |
| 234 | 245 | readonly _tag: "error"; |
| 235 | readonly reason: "not-found" | "expired"; | |
| 246 | readonly reason: "not-found" | "expired" | "scope-not-granted"; | |
| 236 | 247 | readonly error: BootstrapCredentialError; |
| 237 | 248 | } |
| 238 | 249 | | { |
| ... | ... | @@ -493,6 +504,17 @@ export const make = Effect.gen(function* () { |
| 493 | 504 | ]; |
| 494 | 505 | } |
| 495 | 506 | |
| 507 | if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) { | |
| 508 | return [ | |
| 509 | { | |
| 510 | _tag: "error", | |
| 511 | reason: "scope-not-granted", | |
| 512 | error: new BootstrapCredentialScopeNotGrantedError({}), | |
| 513 | }, | |
| 514 | current, | |
| 515 | ]; | |
| 516 | } | |
| 517 | ||
| 496 | 518 | const remainingUses = grant.remainingUses; |
| 497 | 519 | if (typeof remainingUses === "number") { |
| 498 | 520 | if (remainingUses <= 1) { |
| ... | ... | @@ -531,10 +553,16 @@ export const make = Effect.gen(function* () { |
| 531 | 553 | return yield* seededResult.error; |
| 532 | 554 | } |
| 533 | 555 | |
| 556 | // The scope check is part of the UPDATE's WHERE clause so a rejected | |
| 557 | // request cannot consume a one-time link. The re-check below only | |
| 558 | // explains why nothing matched. | |
| 534 | 559 | const consumed = yield* pairingLinks |
| 535 | 560 | .consumeAvailable({ |
| 536 | 561 | credential, |
| 537 | 562 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, |
| 563 | ...(input?.requestedScopes !== undefined | |
| 564 | ? { requestedScopes: input.requestedScopes } | |
| 565 | : {}), | |
| 538 | 566 | consumedAt: now, |
| 539 | 567 | now, |
| 540 | 568 | }) |
| ... | ... | @@ -580,6 +608,10 @@ export const make = Effect.gen(function* () { |
| 580 | 608 | return yield* new BootstrapCredentialProofKeyMismatchError({}); |
| 581 | 609 | } |
| 582 | 610 | |
| 611 | if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) { | |
| 612 | return yield* new BootstrapCredentialScopeNotGrantedError({}); | |
| 613 | } | |
| 614 | ||
| 583 | 615 | return yield* new UnavailableBootstrapCredentialError({}); |
| 584 | 616 | }, |
| 585 | 617 | ); |
apps/server/src/auth/SessionStore.test.ts+41| ... | ... | @@ -428,6 +428,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 428 | 428 | }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), |
| 429 | 429 | ); |
| 430 | 430 | |
| 431 | it.effect.each(["insert", "revoke"] as const)( | |
| 432 | "keeps existing browser sessions valid when replacement cannot %s", | |
| 433 | (operation) => | |
| 434 | Effect.gen(function* () { | |
| 435 | const sessions = yield* SessionStore.SessionStore; | |
| 436 | const sql = yield* SqlClient.SqlClient; | |
| 437 | const previous = yield* sessions.issue({ subject: "one-time-token" }); | |
| 438 | const unrelated = yield* sessions.issue({ subject: "one-time-token" }); | |
| 439 | if (operation === "insert") { | |
| 440 | yield* sql` | |
| 441 | CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions | |
| 442 | BEGIN | |
| 443 | SELECT RAISE(ABORT, 'simulated insert failure'); | |
| 444 | END | |
| 445 | `; | |
| 446 | } else { | |
| 447 | yield* sql` | |
| 448 | CREATE TRIGGER reject_auth_session_revocation BEFORE UPDATE OF revoked_at ON auth_sessions | |
| 449 | BEGIN | |
| 450 | SELECT RAISE(ABORT, 'simulated revocation failure'); | |
| 451 | END | |
| 452 | `; | |
| 453 | } | |
| 454 | ||
| 455 | const error = yield* sessions | |
| 456 | .issue({ | |
| 457 | subject: "replacement-pairing", | |
| 458 | scopes: ["orchestration:read"], | |
| 459 | replaceSessionId: previous.sessionId, | |
| 460 | }) | |
| 461 | .pipe(Effect.flip); | |
| 462 | ||
| 463 | expect(error._tag).toBe("SessionCredentialIssueError"); | |
| 464 | expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); | |
| 465 | expect((yield* sessions.verify(unrelated.token)).sessionId).toBe(unrelated.sessionId); | |
| 466 | expect((yield* sessions.listActive()).map((session) => session.sessionId).sort()).toEqual( | |
| 467 | [previous.sessionId, unrelated.sessionId].sort(), | |
| 468 | ); | |
| 469 | }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), | |
| 470 | ); | |
| 471 | ||
| 431 | 472 | it.effect("rejects websocket tokens once the parent session has expired", () => |
| 432 | 473 | Effect.gen(function* () { |
| 433 | 474 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+10-2| ... | ... | @@ -383,6 +383,8 @@ export class SessionStore extends Context.Service< |
| 383 | 383 | * before storing this session. |
| 384 | 384 | */ |
| 385 | 385 | readonly replaceActiveForSubjectAndMethod?: boolean; |
| 386 | /** Replace only this session, of the same method, in the issuance transaction. */ | |
| 387 | readonly replaceSessionId?: AuthSessionId; | |
| 386 | 388 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 387 | 389 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 388 | 390 | readonly issueWebSocketToken: ( |
| ... | ... | @@ -708,8 +710,14 @@ export const make = Effect.gen(function* () { |
| 708 | 710 | expiresAt, |
| 709 | 711 | } satisfies AuthSessions.CreateAuthSessionInput; |
| 710 | 712 | const replacedSessionIds = yield* ( |
| 711 | input?.replaceActiveForSubjectAndMethod | |
| 712 | ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) | |
| 713 | input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod | |
| 714 | ? authSessions.createReplacingActive({ | |
| 715 | session: sessionRecord, | |
| 716 | revokedAt: issuedAt, | |
| 717 | ...(input.replaceSessionId !== undefined | |
| 718 | ? { replaceSessionId: input.replaceSessionId } | |
| 719 | : {}), | |
| 720 | }) | |
| 713 | 721 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) |
| 714 | 722 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); |
| 715 | 723 | if (replacedSessionIds.length > 0) { |
apps/server/src/auth/http.ts+9| ... | ... | @@ -303,9 +303,18 @@ export const layer = HttpApiBuilder.group( |
| 303 | 303 | function* (args) { |
| 304 | 304 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 305 | 305 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 306 | const previousCredential = EnvironmentAuth.selectRequestCredential( | |
| 307 | request, | |
| 308 | sessions.cookieName, | |
| 309 | sessions.legacyCookieName, | |
| 310 | ); | |
| 306 | 311 | const result = yield* serverAuth.createBrowserSession( |
| 307 | 312 | args.payload.credential, |
| 308 | 313 | deriveAuthClientMetadata({ request }), |
| 314 | previousCredential?.source === "cookie" || | |
| 315 | previousCredential?.source === "legacy-cookie" | |
| 316 | ? previousCredential.token | |
| 317 | : undefined, | |
| 309 | 318 | ); |
| 310 | 319 | const cookieName = result.cookieName ?? sessions.cookieName; |
| 311 | 320 | const selectedCookie = yield* Effect.fromResult( |
apps/server/src/cli/auth.ts+5-2| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | formatSessionList, |
| 20 | 20 | } from "../cliAuthFormat.ts"; |
| 21 | 21 | import * as ServerConfig from "../config.ts"; |
| 22 | import { authScopesFlag } from "./authScopes.ts"; | |
| 22 | 23 | import { |
| 23 | 24 | authLocationFlags, |
| 24 | 25 | type CliAuthLocationFlags, |
| ... | ... | @@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.Boolean("token-only").pipe( |
| 83 | 84 | |
| 84 | 85 | const pairingCreateCommand = Command.make("create", { |
| 85 | 86 | ...authLocationFlags, |
| 87 | scopes: authScopesFlag(AuthStandardClientScopes), | |
| 86 | 88 | ttl: ttlFlag, |
| 87 | 89 | label: labelFlag, |
| 88 | 90 | baseUrl: baseUrlFlag, |
| ... | ... | @@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", { |
| 95 | 97 | (environmentAuth) => |
| 96 | 98 | Effect.gen(function* () { |
| 97 | 99 | const issued = yield* environmentAuth.createPairingLink({ |
| 98 | scopes: AuthStandardClientScopes, | |
| 100 | scopes: flags.scopes, | |
| 99 | 101 | subject: "one-time-token", |
| 100 | 102 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 101 | 103 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| ... | ... | @@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe( |
| 161 | 163 | |
| 162 | 164 | const sessionIssueCommand = Command.make("issue", { |
| 163 | 165 | ...authLocationFlags, |
| 166 | scopes: authScopesFlag(AuthAdministrativeScopes), | |
| 164 | 167 | ttl: ttlFlag, |
| 165 | 168 | label: labelFlag, |
| 166 | 169 | subject: subjectFlag, |
| ... | ... | @@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", { |
| 174 | 177 | (environmentAuth) => |
| 175 | 178 | Effect.gen(function* () { |
| 176 | 179 | const issued = yield* environmentAuth.issueSession({ |
| 177 | scopes: AuthAdministrativeScopes, | |
| 180 | scopes: flags.scopes, | |
| 178 | 181 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 179 | 182 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| 180 | 183 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), |
apps/server/src/cli/authScopes.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { AuthEnvironmentScope } from "@t3tools/contracts"; | |
| 2 | import { Flag } from "effect/cli"; | |
| 3 | ||
| 4 | export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) => | |
| 5 | Flag.Literals("scope", AuthEnvironmentScope.literals).pipe( | |
| 6 | Flag.withDescription( | |
| 7 | `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`, | |
| 8 | ), | |
| 9 | Flag.atLeast(0), | |
| 10 | Flag.map((scopes) => [...new Set(scopes.length > 0 ? scopes : defaults)]), | |
| 11 | ); |
apps/server/src/cli/pair.test.ts+114-2| ... | ... | @@ -5,13 +5,14 @@ import * as NodeOS from "node:os"; |
| 5 | 5 | import * as NodePath from "node:path"; |
| 6 | 6 | |
| 7 | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { AuthStandardClientScopes } from "@t3tools/contracts"; | |
| 8 | 9 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | 10 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | 11 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Layer from "effect/Layer"; |
| 13 | 14 | import * as TestConsole from "effect/testing/TestConsole"; |
| 14 | import { Command } from "effect/cli"; | |
| 15 | import { Command, CliError } from "effect/cli"; | |
| 15 | 16 | |
| 16 | 17 | import { cli } from "../binCli.ts"; |
| 17 | 18 | import { |
| ... | ... | @@ -173,9 +174,13 @@ describe("t3 pair", () => { |
| 173 | 174 | const listed = yield* captureStdout( |
| 174 | 175 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), |
| 175 | 176 | ); |
| 176 | const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>; | |
| 177 | const credentials = JSON.parse(listed) as ReadonlyArray<{ | |
| 178 | readonly label?: string; | |
| 179 | readonly scopes: ReadonlyArray<string>; | |
| 180 | }>; | |
| 177 | 181 | assert.equal(credentials.length, 1); |
| 178 | 182 | assert.equal(credentials[0]?.label, "t3 pair"); |
| 183 | assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes); | |
| 179 | 184 | }), |
| 180 | 185 | ).pipe( |
| 181 | 186 | Effect.provide(NodeServices.layer), |
| ... | ... | @@ -195,6 +200,43 @@ describe("t3 pair", () => { |
| 195 | 200 | ), |
| 196 | 201 | ); |
| 197 | 202 | |
| 203 | it.effect("mints a pairing grant with only the selected scopes", () => | |
| 204 | withDescriptorServer((origin) => | |
| 205 | Effect.gen(function* () { | |
| 206 | const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-scopes-test-")); | |
| 207 | yield* persistServerRuntimeState({ | |
| 208 | path: NodePath.join(baseDir, "userdata", "server-runtime.json"), | |
| 209 | state: yield* makePersistedServerRuntimeState({ | |
| 210 | config: { host: "127.0.0.1", devUrl: undefined }, | |
| 211 | port: Number(new URL(origin).port), | |
| 212 | }), | |
| 213 | }); | |
| 214 | ||
| 215 | yield* captureStdout( | |
| 216 | runCli([ | |
| 217 | "pair", | |
| 218 | "--base-dir", | |
| 219 | baseDir, | |
| 220 | "--scope", | |
| 221 | "orchestration:read", | |
| 222 | "--scope", | |
| 223 | "relay:read", | |
| 224 | "--scope", | |
| 225 | "orchestration:read", | |
| 226 | ]), | |
| 227 | ); | |
| 228 | const listed = yield* captureStdout( | |
| 229 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | |
| 230 | ); | |
| 231 | const credentials = JSON.parse(listed) as ReadonlyArray<{ | |
| 232 | readonly scopes: ReadonlyArray<string>; | |
| 233 | }>; | |
| 234 | assert.lengthOf(credentials, 1); | |
| 235 | assert.deepEqual(credentials[0]?.scopes, ["orchestration:read", "relay:read"]); | |
| 236 | }), | |
| 237 | ).pipe(Effect.provide(NodeServices.layer)), | |
| 238 | ); | |
| 239 | ||
| 198 | 240 | it.effect("pairs through the recorded dev web URL for dev servers", () => |
| 199 | 241 | withDescriptorServer((origin) => |
| 200 | 242 | Effect.gen(function* () { |
| ... | ... | @@ -288,3 +330,73 @@ describe("t3 pair", () => { |
| 288 | 330 | }).pipe(Effect.provide(NodeServices.layer)), |
| 289 | 331 | ); |
| 290 | 332 | }); |
| 333 | ||
| 334 | describe("auth scope options", () => { | |
| 335 | it.effect.each([ | |
| 336 | { group: "pairing", action: "create" }, | |
| 337 | { group: "session", action: "issue" }, | |
| 338 | ] as const)( | |
| 339 | "issues and persists only the selected scopes for auth $group $action", | |
| 340 | ({ group, action }) => | |
| 341 | Effect.gen(function* () { | |
| 342 | const baseDir = NodeFS.mkdtempSync( | |
| 343 | NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-scopes-test-"), | |
| 344 | ); | |
| 345 | const output = yield* captureStdout( | |
| 346 | runCli([ | |
| 347 | "auth", | |
| 348 | group, | |
| 349 | action, | |
| 350 | "--base-dir", | |
| 351 | baseDir, | |
| 352 | "--json", | |
| 353 | "--scope", | |
| 354 | "orchestration:read", | |
| 355 | "--scope", | |
| 356 | "access:read", | |
| 357 | "--scope", | |
| 358 | "orchestration:read", | |
| 359 | ]), | |
| 360 | ); | |
| 361 | const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray<string> }; | |
| 362 | const listOutput = yield* captureStdout( | |
| 363 | runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]), | |
| 364 | ); | |
| 365 | const listed = JSON.parse(listOutput) as ReadonlyArray<{ | |
| 366 | readonly scopes: ReadonlyArray<string>; | |
| 367 | }>; | |
| 368 | ||
| 369 | assert.deepEqual(issued.scopes, ["orchestration:read", "access:read"]); | |
| 370 | assert.lengthOf(listed, 1); | |
| 371 | assert.deepEqual(listed[0]?.scopes, issued.scopes); | |
| 372 | }), | |
| 373 | ); | |
| 374 | ||
| 375 | it.effect.each( | |
| 376 | [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]].map((command) => ({ | |
| 377 | command, | |
| 378 | label: command.join(" "), | |
| 379 | })), | |
| 380 | )("rejects invalid scopes before running $label", ({ command }) => | |
| 381 | Effect.gen(function* () { | |
| 382 | const error = yield* runCli([ | |
| 383 | ...command, | |
| 384 | "--scope", | |
| 385 | "orchestration:read", | |
| 386 | "--scope", | |
| 387 | "admin", | |
| 388 | ]).pipe(Effect.provide(layerCliRuntime), Effect.flip); | |
| 389 | ||
| 390 | if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { | |
| 391 | assert.fail(`Expected ShowHelp, got ${String(error)}`); | |
| 392 | } | |
| 393 | assert.deepEqual(error.commandPath, ["t3", ...command]); | |
| 394 | const scopeError = error.errors[0]; | |
| 395 | if (scopeError?._tag !== "InvalidValue") { | |
| 396 | assert.fail(`Expected InvalidValue, got ${String(scopeError?._tag)}`); | |
| 397 | } | |
| 398 | assert.equal(scopeError.option, "scope"); | |
| 399 | assert.equal(scopeError.value, "admin"); | |
| 400 | }), | |
| 401 | ); | |
| 402 | }); |
apps/server/src/cli/pair.ts+11-2| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | * HTTPS and pairs through the tailnet URL instead. |
| 11 | 11 | */ |
| 12 | 12 | import { |
| 13 | type AuthEnvironmentScope, | |
| 13 | 14 | AuthStandardClientScopes, |
| 14 | 15 | ExecutionEnvironmentDescriptor, |
| 15 | 16 | PortSchema, |
| ... | ... | @@ -51,6 +52,7 @@ import { |
| 51 | 52 | renderTerminalQrCode, |
| 52 | 53 | resolveHeadlessConnectionString, |
| 53 | 54 | } from "../startupAccess.ts"; |
| 55 | import { authScopesFlag } from "./authScopes.ts"; | |
| 54 | 56 | import { baseDirFlag, DurationFromString } from "./config.ts"; |
| 55 | 57 | |
| 56 | 58 | const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; |
| ... | ... | @@ -426,13 +428,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" |
| 426 | 428 | |
| 427 | 429 | const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { |
| 428 | 430 | readonly config: ServerConfig.ServerConfig["Service"]; |
| 431 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 429 | 432 | readonly ttl: Option.Option<Duration.Duration>; |
| 430 | 433 | readonly label: Option.Option<string>; |
| 431 | 434 | }) { |
| 432 | 435 | return yield* Effect.gen(function* () { |
| 433 | 436 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 434 | 437 | return yield* environmentAuth.createPairingLink({ |
| 435 | scopes: AuthStandardClientScopes, | |
| 438 | scopes: input.scopes, | |
| 436 | 439 | subject: "one-time-token", |
| 437 | 440 | label: Option.getOrElse(input.label, () => "t3 pair"), |
| 438 | 441 | ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), |
| ... | ... | @@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( |
| 475 | 478 | |
| 476 | 479 | export const pairCommand = Command.make("pair", { |
| 477 | 480 | baseDir: baseDirFlag, |
| 481 | scopes: authScopesFlag(AuthStandardClientScopes), | |
| 478 | 482 | ttl: ttlFlag, |
| 479 | 483 | label: labelFlag, |
| 480 | 484 | tailscale: tailscaleFlag, |
| ... | ... | @@ -516,7 +520,12 @@ export const pairCommand = Command.make("pair", { |
| 516 | 520 | } |
| 517 | 521 | |
| 518 | 522 | const config = yield* makePairServerConfig({ target, logLevel }); |
| 519 | const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); | |
| 523 | const issued = yield* mintPairingLink({ | |
| 524 | config, | |
| 525 | scopes: flags.scopes, | |
| 526 | ttl: flags.ttl, | |
| 527 | label: flags.label, | |
| 528 | }); | |
| 520 | 529 | const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); |
| 521 | 530 | |
| 522 | 531 | yield* Console.log( |
apps/server/src/persistence/AuthPairingLinks.ts+9-1| ... | ... | @@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; |
| 46 | 46 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ |
| 47 | 47 | credential: Schema.String, |
| 48 | 48 | proofKeyThumbprint: Schema.NullOr(Schema.String), |
| 49 | requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), | |
| 49 | 50 | consumedAt: Schema.DateTimeUtcFromString, |
| 50 | 51 | now: Schema.DateTimeUtcFromString, |
| 51 | 52 | }); |
| ... | ... | @@ -159,7 +160,7 @@ export const make = Effect.gen(function* () { |
| 159 | 160 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ |
| 160 | 161 | Request: ConsumeAuthPairingLinkInput, |
| 161 | 162 | Result: AuthPairingLinkRawDbRow, |
| 162 | execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => | |
| 163 | execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => | |
| 163 | 164 | sql` |
| 164 | 165 | UPDATE auth_pairing_links |
| 165 | 166 | SET consumed_at = ${consumedAt} |
| ... | ... | @@ -171,6 +172,13 @@ export const make = Effect.gen(function* () { |
| 171 | 172 | proof_key_thumbprint IS NULL |
| 172 | 173 | OR proof_key_thumbprint = ${proofKeyThumbprint} |
| 173 | 174 | ) |
| 175 | AND NOT EXISTS ( | |
| 176 | SELECT 1 | |
| 177 | FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested | |
| 178 | WHERE requested.value NOT IN ( | |
| 179 | SELECT value FROM json_each(auth_pairing_links.scopes) | |
| 180 | ) | |
| 181 | ) | |
| 174 | 182 | RETURNING |
| 175 | 183 | id AS "id", |
| 176 | 184 | credential AS "credential", |
apps/server/src/persistence/AuthSessions.ts+7-2| ... | ... | @@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; |
| 58 | 58 | export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ |
| 59 | 59 | session: CreateAuthSessionInput, |
| 60 | 60 | revokedAt: Schema.DateTimeUtcFromString, |
| 61 | replaceSessionId: Schema.optionalKey(AuthSessionId), | |
| 61 | 62 | }); |
| 62 | 63 | export type CreateReplacingActiveAuthSessionInput = |
| 63 | 64 | typeof CreateReplacingActiveAuthSessionInput.Type; |
| ... | ... | @@ -276,11 +277,15 @@ export const make = Effect.gen(function* () { |
| 276 | 277 | const revokeActiveSessionsForReplacement = SqlSchema.findAll({ |
| 277 | 278 | Request: CreateReplacingActiveAuthSessionInput, |
| 278 | 279 | Result: Schema.Struct({ sessionId: AuthSessionId }), |
| 279 | execute: ({ session, revokedAt }) => | |
| 280 | execute: ({ session, revokedAt, replaceSessionId }) => | |
| 280 | 281 | sql` |
| 281 | 282 | UPDATE auth_sessions |
| 282 | 283 | SET revoked_at = ${revokedAt} |
| 283 | WHERE subject = ${session.subject} | |
| 284 | WHERE ${ | |
| 285 | replaceSessionId === undefined | |
| 286 | ? sql`subject = ${session.subject}` | |
| 287 | : sql`session_id = ${replaceSessionId}` | |
| 288 | } | |
| 284 | 289 | AND method = ${session.method} |
| 285 | 290 | AND revoked_at IS NULL |
| 286 | 291 | AND expires_at > ${revokedAt} |
apps/web/src/authBootstrap.test.ts+153-6| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type AuthSessionState, |
| 6 | 6 | type DesktopBridge, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import { createBrowserHistory } from "@tanstack/react-router"; | |
| 8 | 9 | import * as DateTime from "effect/DateTime"; |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| ... | ... | @@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./ |
| 16 | 17 | type TestWindow = { |
| 17 | 18 | location: URL; |
| 18 | 19 | history: { |
| 19 | replaceState: (_data: unknown, _unused: string, url: string) => void; | |
| 20 | state: unknown; | |
| 21 | replaceState: (_data: unknown, _unused: string, url?: string) => void; | |
| 20 | 22 | }; |
| 21 | 23 | desktopBridge?: DesktopBridge; |
| 22 | 24 | }; |
| ... | ... | @@ -59,8 +61,12 @@ function installTestBrowser(url: string) { |
| 59 | 61 | const testWindow: TestWindow = { |
| 60 | 62 | location: new URL(url), |
| 61 | 63 | history: { |
| 62 | replaceState: (_data, _unused, nextUrl) => { | |
| 63 | testWindow.location = new URL(nextUrl, testWindow.location.href); | |
| 64 | state: null, | |
| 65 | replaceState: (data, _unused, nextUrl) => { | |
| 66 | testWindow.history.state = data; | |
| 67 | if (nextUrl !== undefined) { | |
| 68 | testWindow.location = new URL(nextUrl, testWindow.location.href); | |
| 69 | } | |
| 64 | 70 | }, |
| 65 | 71 | }, |
| 66 | 72 | }; |
| ... | ... | @@ -91,6 +97,14 @@ function sequence<A>(...values: ReadonlyArray<A>) { |
| 91 | 97 | return () => values[Math.min(index++, values.length - 1)]!; |
| 92 | 98 | } |
| 93 | 99 | |
| 100 | function createSignal() { | |
| 101 | let resolve = () => {}; | |
| 102 | const promise = new Promise<void>((resolvePromise) => { | |
| 103 | resolve = resolvePromise; | |
| 104 | }); | |
| 105 | return { promise, resolve }; | |
| 106 | } | |
| 107 | ||
| 94 | 108 | let disposeHttpTest: (() => Promise<void>) | undefined; |
| 95 | 109 | |
| 96 | 110 | async function installAuthApi(input: { |
| ... | ... | @@ -286,6 +300,139 @@ describe("resolveInitialServerAuthGateState", () => { |
| 286 | 300 | expect(testWindow.location.searchParams.get("token")).toBeNull(); |
| 287 | 301 | }); |
| 288 | 302 | |
| 303 | it.each([ | |
| 304 | { suffix: "#token=replacement-token", cached: false }, | |
| 305 | { suffix: "#token=replacement-token", cached: true }, | |
| 306 | { suffix: "?token=replacement-token", cached: true }, | |
| 307 | { suffix: "/#token=replacement-token", cached: true }, | |
| 308 | ])( | |
| 309 | "re-pairs an authenticated browser with $suffix when cached=$cached", | |
| 310 | async ({ suffix, cached }) => { | |
| 311 | let scopes: AuthBrowserSessionResult["scopes"] = ["orchestration:read"]; | |
| 312 | const testApi = await installAuthApi({ | |
| 313 | session: () => ({ ...authenticatedSession(LOOPBACK_AUTH), scopes }), | |
| 314 | browserSession: () => | |
| 315 | Effect.sync(() => { | |
| 316 | scopes = ["orchestration:read", "orchestration:operate"]; | |
| 317 | return browserSession(scopes); | |
| 318 | }), | |
| 319 | }); | |
| 320 | const testWindow = installTestBrowser("http://localhost/"); | |
| 321 | const { | |
| 322 | resolveInitialServerAuthGateState, | |
| 323 | submitServerAuthCredential, | |
| 324 | takePairingTokenFromUrl, | |
| 325 | } = await import("./environments/primary"); | |
| 326 | const { fetchSessionState } = await import("./environments/primary/auth"); | |
| 327 | ||
| 328 | if (cached) { | |
| 329 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 330 | status: "authenticated", | |
| 331 | }); | |
| 332 | } | |
| 333 | ||
| 334 | testWindow.location = new URL(`http://localhost/pair${suffix}`); | |
| 335 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 336 | status: "requires-auth", | |
| 337 | auth: LOOPBACK_AUTH, | |
| 338 | }); | |
| 339 | expect(testApi.calls.browserSession).toEqual([]); | |
| 340 | ||
| 341 | const token = takePairingTokenFromUrl(); | |
| 342 | expect(token).toBe("replacement-token"); | |
| 343 | await submitServerAuthCredential(token!); | |
| 344 | ||
| 345 | expect(testApi.calls.browserSession).toEqual([{ credential: "replacement-token" }]); | |
| 346 | await expect(fetchSessionState()).resolves.toMatchObject({ | |
| 347 | authenticated: true, | |
| 348 | scopes: ["orchestration:read", "orchestration:operate"], | |
| 349 | }); | |
| 350 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 351 | status: "authenticated", | |
| 352 | }); | |
| 353 | }, | |
| 354 | ); | |
| 355 | ||
| 356 | it.each([false, true])( | |
| 357 | "keeps rejected replacement pairing open when cached=%s", | |
| 358 | async (cached) => { | |
| 359 | const existingSession = { | |
| 360 | ...authenticatedSession(LOOPBACK_AUTH), | |
| 361 | scopes: ["orchestration:read"] as const, | |
| 362 | }; | |
| 363 | const exchangeStarted = createSignal(); | |
| 364 | const finishExchange = createSignal(); | |
| 365 | const testApi = await installAuthApi({ | |
| 366 | session: () => existingSession, | |
| 367 | browserSession: () => | |
| 368 | Effect.gen(function* () { | |
| 369 | exchangeStarted.resolve(); | |
| 370 | yield* Effect.promise(() => finishExchange.promise); | |
| 371 | return yield* new EnvironmentAuthInvalidError({ | |
| 372 | code: "auth_invalid", | |
| 373 | reason: "invalid_credential", | |
| 374 | traceId: "trace-invalid-replacement", | |
| 375 | }); | |
| 376 | }), | |
| 377 | }); | |
| 378 | const testWindow = installTestBrowser("http://localhost/"); | |
| 379 | const { | |
| 380 | resolveInitialServerAuthGateState, | |
| 381 | submitServerAuthCredential, | |
| 382 | takePairingTokenFromUrl, | |
| 383 | } = await import("./environments/primary"); | |
| 384 | const { fetchSessionState } = await import("./environments/primary/auth"); | |
| 385 | if (cached) { | |
| 386 | await resolveInitialServerAuthGateState(); | |
| 387 | } | |
| 388 | testWindow.location = new URL("http://localhost/pair#token=invalid-replacement"); | |
| 389 | const history = createBrowserHistory({ | |
| 390 | window: { | |
| 391 | get location() { | |
| 392 | return testWindow.location; | |
| 393 | }, | |
| 394 | history: testWindow.history, | |
| 395 | addEventListener() {}, | |
| 396 | removeEventListener() {}, | |
| 397 | }, | |
| 398 | }); | |
| 399 | const gateLoads: Array<ReturnType<typeof resolveInitialServerAuthGateState>> = []; | |
| 400 | const unsubscribe = history.subscribe(() => { | |
| 401 | gateLoads.push(resolveInitialServerAuthGateState()); | |
| 402 | }); | |
| 403 | try { | |
| 404 | const requiresAuth = { status: "requires-auth", auth: LOOPBACK_AUTH }; | |
| 405 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); | |
| 406 | ||
| 407 | // The pairing form strips the token before submitting. TanStack history | |
| 408 | // reloads the auth gate synchronously when replaceState runs. | |
| 409 | const token = takePairingTokenFromUrl(); | |
| 410 | expect(gateLoads).toHaveLength(1); | |
| 411 | await expect(gateLoads[0]).resolves.toEqual(requiresAuth); | |
| 412 | const rejected = expect(submitServerAuthCredential(token!)).rejects.toMatchObject({ | |
| 413 | _tag: "PrimaryEnvironmentPairingCredentialRejectedError", | |
| 414 | message: "Invalid pairing token. Check the token and try again.", | |
| 415 | }); | |
| 416 | await exchangeStarted.promise; | |
| 417 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); | |
| 418 | finishExchange.resolve(); | |
| 419 | await rejected; | |
| 420 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); | |
| 421 | expect(testApi.calls.browserSession).toEqual([{ credential: "invalid-replacement" }]); | |
| 422 | await expect(fetchSessionState()).resolves.toEqual(existingSession); | |
| 423 | ||
| 424 | testWindow.history.replaceState({}, "", "/"); | |
| 425 | await expect(gateLoads[1]).resolves.toEqual({ status: "authenticated" }); | |
| 426 | testWindow.history.replaceState({}, "", "/pair"); | |
| 427 | await expect(gateLoads[2]).resolves.toEqual({ status: "authenticated" }); | |
| 428 | } finally { | |
| 429 | finishExchange.resolve(); | |
| 430 | unsubscribe(); | |
| 431 | history.destroy(); | |
| 432 | } | |
| 433 | }, | |
| 434 | ); | |
| 435 | ||
| 289 | 436 | it("allows manual token submission after the initial auth check requires pairing", async () => { |
| 290 | 437 | const nextSession = sequence( |
| 291 | 438 | unauthenticatedSession(LOOPBACK_AUTH), |
| ... | ... | @@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 522 | 669 | expect(testWindow.location.hash).toBe(""); |
| 523 | 670 | }); |
| 524 | 671 | |
| 525 | it("exchanges an explicit pair link after caching an authenticated state", async () => { | |
| 672 | it("exchanges a root URL token after caching an authenticated state", async () => { | |
| 526 | 673 | const testApi = await installAuthApi({ |
| 527 | 674 | session: () => authenticatedSession(LOOPBACK_AUTH), |
| 528 | 675 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| ... | ... | @@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 533 | 680 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 534 | 681 | status: "authenticated", |
| 535 | 682 | }); |
| 536 | testWindow.location = new URL("http://localhost/pair#token=reusable-token"); | |
| 683 | testWindow.location = new URL("http://localhost/#token=reusable-token"); | |
| 537 | 684 | |
| 538 | 685 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); |
| 539 | 686 | |
| ... | ... | @@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 575 | 722 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 576 | 723 | |
| 577 | 724 | const initialBootstrap = resolveInitialServerAuthGateState(); |
| 578 | testWindow.location = new URL("http://localhost/pair#token=reusable-token"); | |
| 725 | testWindow.location = new URL("http://localhost/#token=reusable-token"); | |
| 579 | 726 | const explicitPairing = resolveInitialServerAuthGateState(); |
| 580 | 727 | const laterCaller = resolveInitialServerAuthGateState(); |
| 581 | 728 | let laterCallerSettled = false; |
apps/web/src/connection/platform.ts+2-1| ... | ... | @@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext( |
| 228 | 228 | Effect.sync(() => { |
| 229 | 229 | const presentation = ClientCapabilities.ClientPresentation.of({ |
| 230 | 230 | metadata: clientMetadata(), |
| 231 | scopes: AuthStandardClientScopes, | |
| 232 | 231 | }); |
| 233 | 232 | const cloudSession = ClientCapabilities.CloudSession.of({ |
| 234 | 233 | identity: Effect.sync(() => |
| ... | ... | @@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn( |
| 383 | 382 | Effect.mapError(mapRemoteEnvironmentError), |
| 384 | 383 | ); |
| 385 | 384 | const issuedAtEpochMs = yield* Clock.currentTimeMillis; |
| 385 | // The desktop seed grant is administrative so the primary window can manage | |
| 386 | // access; a secondary backend session only needs to operate its environment. | |
| 386 | 387 | const access = yield* bootstrapRemoteBearerSession({ |
| 387 | 388 | httpBaseUrl, |
| 388 | 389 | credential: entry.bootstrapToken, |
apps/web/src/environments/primary/auth.ts+16| ... | ... | @@ -140,6 +140,7 @@ type ServerAuthGateState = |
| 140 | 140 | |
| 141 | 141 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; |
| 142 | 142 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; |
| 143 | let explicitPairingRequested = false; | |
| 143 | 144 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; |
| 144 | 145 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; |
| 145 | 146 | |
| ... | ... | @@ -347,6 +348,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 347 | 348 | await waitForAuthenticatedSessionAfterBootstrap(); |
| 348 | 349 | resolvedAuthenticatedGateState = { status: "authenticated" }; |
| 349 | 350 | bootstrapPromise = null; |
| 351 | explicitPairingRequested = false; | |
| 350 | 352 | stripPairingTokenFromUrl(); |
| 351 | 353 | } |
| 352 | 354 | |
| ... | ... | @@ -428,6 +430,19 @@ export async function revokeOtherServerClientSessions(): Promise<number> { |
| 428 | 430 | } |
| 429 | 431 | |
| 430 | 432 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
| 433 | // An explicit pairing link replaces this browser's grant, even when the | |
| 434 | // current cookie or a cached gate already authenticates it. Keep that intent | |
| 435 | // after stripping the token, which causes the router to load this gate again. | |
| 436 | if (window.location.pathname.replace(/\/+$/, "") !== "/pair") { | |
| 437 | explicitPairingRequested = false; | |
| 438 | } else if (peekPairingTokenFromUrl()) { | |
| 439 | explicitPairingRequested = true; | |
| 440 | } | |
| 441 | if (explicitPairingRequested) { | |
| 442 | const currentSession = await fetchSessionState(); | |
| 443 | return { status: "requires-auth", auth: currentSession.auth }; | |
| 444 | } | |
| 445 | ||
| 431 | 446 | const urlCredential = takePairingTokenFromUrl(); |
| 432 | 447 | const previousPromise = bootstrapPromise; |
| 433 | 448 | if (urlCredential) { |
| ... | ... | @@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat |
| 468 | 483 | export function __resetServerAuthBootstrapForTests() { |
| 469 | 484 | bootstrapPromise = null; |
| 470 | 485 | resolvedAuthenticatedGateState = null; |
| 486 | explicitPairingRequested = false; | |
| 471 | 487 | } |
apps/web/src/routes/pair.tsx+7-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; | |
| 1 | import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | HostedPairingRouteSurface, |
| ... | ... | @@ -27,8 +27,8 @@ export const Route = createFileRoute("/pair")({ |
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | 29 | function PairRouteView() { |
| 30 | const router = useRouter(); | |
| 30 | 31 | const { authGateState } = Route.useRouteContext(); |
| 31 | const navigate = useNavigate(); | |
| 32 | 32 | |
| 33 | 33 | if (!authGateState) { |
| 34 | 34 | return null; |
| ... | ... | @@ -42,7 +42,11 @@ function PairRouteView() { |
| 42 | 42 | <PairingRouteSurface |
| 43 | 43 | auth={authGateState.auth} |
| 44 | 44 | onAuthenticated={() => { |
| 45 | void navigate({ to: "/", replace: true }); | |
| 45 | // Recreate the primary connection so its WebSocket and cached scopes | |
| 46 | // use the newly issued cookie after re-pairing. | |
| 47 | router.history.replace("/"); | |
| 48 | router.history.flush(); | |
| 49 | window.location.reload(); | |
| 46 | 50 | }} |
| 47 | 51 | {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} |
| 48 | 52 | /> |
docs/user/remote-access.md+19| ... | ... | @@ -258,6 +258,25 @@ management is available through `t3 auth --help`. |
| 258 | 258 | A session with an open connection stays listed after its access credential |
| 259 | 259 | expires. |
| 260 | 260 | |
| 261 | To choose a token's permissions, pass `--scope` once for each scope you want: | |
| 262 | ||
| 263 | ```sh | |
| 264 | npx t3 pair --scope orchestration:read --scope relay:read | |
| 265 | ``` | |
| 266 | ||
| 267 | The selected scopes replace the default permissions. The same option works with | |
| 268 | `npx t3 auth pairing create` and `npx t3 auth session issue`; each command's | |
| 269 | `--help` lists the available scopes. Without `--scope`, pairing tokens retain | |
| 270 | standard client permissions and issued bearer sessions retain administrative | |
| 271 | permissions. | |
| 272 | ||
| 273 | To change an existing client's permissions, create a fresh pairing link with the | |
| 274 | scopes it needs. In a browser opened directly on the environment, open that link | |
| 275 | to replace the browser's current grant. For mobile or a saved remote environment | |
| 276 | in web or desktop, use **Add Environment** with the fresh link or code; pairing | |
| 277 | the same environment replaces its saved grant. Reconnecting alone does not change | |
| 278 | permissions. | |
| 279 | ||
| 261 | 280 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 262 | 281 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 263 | 282 | revokes its cloud access and frees its host space even when the environment is |
packages/client-runtime/src/authorization/layer.test.ts+88-7| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthAdministrativeScopes, | |
| 3 | AuthStandardClientScopes, | |
| 4 | EnvironmentId, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { |
| 3 | 7 | RelayEnvironmentConnectScope, |
| 4 | 8 | type RelayEnvironmentConnectResponse, |
| ... | ... | @@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = { |
| 46 | 50 | expiresAt: "2026-06-06T01:00:00.000Z", |
| 47 | 51 | }; |
| 48 | 52 | |
| 49 | function recordedFetch(responses: ReadonlyArray<Response>) { | |
| 53 | type RecordedResponse = Response | ((init: RequestInit) => Response); | |
| 54 | ||
| 55 | function recordedFetch(responses: ReadonlyArray<RecordedResponse>) { | |
| 50 | 56 | const calls: Array<readonly [RequestInfo | URL, RequestInit]> = []; |
| 51 | 57 | let responseIndex = 0; |
| 52 | 58 | const fetchFn = ((input, init) => { |
| ... | ... | @@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray<Response>) { |
| 54 | 60 | const response = responses[responseIndex++]; |
| 55 | 61 | return response === undefined |
| 56 | 62 | ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) |
| 57 | : Promise.resolve(response); | |
| 63 | : Promise.resolve(typeof response === "function" ? response(init ?? {}) : response); | |
| 58 | 64 | }) satisfies typeof fetch; |
| 59 | 65 | return { calls, fetchFn }; |
| 60 | 66 | } |
| ... | ... | @@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) => |
| 65 | 71 | expiresAt: "2026-06-06T01:00:00.000Z", |
| 66 | 72 | }); |
| 67 | 73 | |
| 68 | const accessToken = (token: string) => | |
| 74 | const accessToken = (token: string, scope = AuthStandardClientScopes.join(" ")) => | |
| 69 | 75 | Response.json({ |
| 70 | 76 | access_token: token, |
| 71 | 77 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 72 | 78 | token_type: "DPoP", |
| 73 | 79 | expires_in: 3_600, |
| 74 | scope: AuthStandardClientScopes.join(" "), | |
| 80 | scope, | |
| 75 | 81 | }); |
| 76 | 82 | |
| 77 | 83 | const authInvalid = () => |
| ... | ... | @@ -105,7 +111,7 @@ const persistedToken = ( |
| 105 | 111 | |
| 106 | 112 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { |
| 107 | 113 | readonly initialToken?: TokenStore.RemoteDpopAccessToken; |
| 108 | readonly responses: ReadonlyArray<Response>; | |
| 114 | readonly responses: ReadonlyArray<RecordedResponse>; | |
| 109 | 115 | readonly bootstrap?: RelayEnvironmentConnectResponse; |
| 110 | 116 | readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>; |
| 111 | 117 | readonly beforePut?: Effect.Effect<void>; |
| ... | ... | @@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 210 | 216 | deviceType: "mobile", |
| 211 | 217 | os: "test", |
| 212 | 218 | }, |
| 213 | scopes: AuthStandardClientScopes, | |
| 214 | 219 | }), |
| 215 | 220 | ), |
| 216 | 221 | ), |
| ... | ... | @@ -490,6 +495,82 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 490 | 495 | }), |
| 491 | 496 | ); |
| 492 | 497 | |
| 498 | it.effect.each([ | |
| 499 | { name: "read-only", grantScopes: ["orchestration:read"] }, | |
| 500 | { name: "administrative", grantScopes: AuthAdministrativeScopes }, | |
| 501 | ] as const)( | |
| 502 | "inherits $name pairing grant scopes for websocket authorization and HTTP renewal", | |
| 503 | ({ grantScopes }) => | |
| 504 | Effect.gen(function* () { | |
| 505 | const grantedScope = grantScopes.join(" "); | |
| 506 | let exchangeCount = 0; | |
| 507 | const tokenFields = (init: RequestInit) => | |
| 508 | new URLSearchParams( | |
| 509 | init.body instanceof Uint8Array | |
| 510 | ? new TextDecoder().decode(init.body) | |
| 511 | : String(init.body), | |
| 512 | ); | |
| 513 | const exchangeGrant = (init: RequestInit) => { | |
| 514 | const fields = tokenFields(init); | |
| 515 | expect(fields.get("scope")).toBeNull(); | |
| 516 | const scope = fields.get("scope") ?? grantedScope; | |
| 517 | const scopes = scope.split(" "); | |
| 518 | if (scopes.some((requested) => !grantScopes.some((grant) => grant === requested))) { | |
| 519 | return authInvalid(); | |
| 520 | } | |
| 521 | return accessToken(`access-token:${++exchangeCount}:${scopes.join(",")}`, scope); | |
| 522 | }; | |
| 523 | const harness = yield* makeHarness({ | |
| 524 | responses: [ | |
| 525 | Response.json(DESCRIPTOR), | |
| 526 | exchangeGrant, | |
| 527 | websocketTicket("granted-ticket"), | |
| 528 | Response.json(DESCRIPTOR), | |
| 529 | exchangeGrant, | |
| 530 | ], | |
| 531 | }); | |
| 532 | ||
| 533 | const [authorized, refreshed] = yield* Effect.gen(function* () { | |
| 534 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 535 | const first = yield* remote.authorizeDpop({ | |
| 536 | expectedEnvironmentId: ENVIRONMENT_ID, | |
| 537 | }); | |
| 538 | yield* TestClock.adjust("1 hour"); | |
| 539 | const refreshed = yield* remote.authorizeDpopHttp({ | |
| 540 | expectedEnvironmentId: ENVIRONMENT_ID, | |
| 541 | }); | |
| 542 | return [first, refreshed] as const; | |
| 543 | }).pipe(Effect.provide(Layer.merge(harness.layer, TestClock.layer()))); | |
| 544 | ||
| 545 | expect(authorized.socketUrl).toContain("wsTicket=granted-ticket"); | |
| 546 | expect(authorized.httpAuthorization).toMatchObject({ | |
| 547 | _tag: "Dpop", | |
| 548 | accessToken: `access-token:1:${grantScopes.join(",")}`, | |
| 549 | }); | |
| 550 | expect(refreshed.httpAuthorization).toMatchObject({ | |
| 551 | _tag: "Dpop", | |
| 552 | accessToken: `access-token:2:${grantScopes.join(",")}`, | |
| 553 | }); | |
| 554 | expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toMatchObject({ | |
| 555 | accessToken: `access-token:2:${grantScopes.join(",")}`, | |
| 556 | dpopThumbprint: "thumbprint-1", | |
| 557 | }); | |
| 558 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(2); | |
| 559 | const exchanges = harness.fetch.calls.filter(([url]) => | |
| 560 | String(url).endsWith("/oauth/token"), | |
| 561 | ); | |
| 562 | expect(exchanges).toHaveLength(2); | |
| 563 | for (const [, init] of exchanges) { | |
| 564 | expect(Object.fromEntries(tokenFields(init))).toMatchObject({ | |
| 565 | subject_token: BOOTSTRAP.credential, | |
| 566 | client_label: "T3 Code Test", | |
| 567 | client_device_type: "mobile", | |
| 568 | client_os: "test", | |
| 569 | }); | |
| 570 | } | |
| 571 | }), | |
| 572 | ); | |
| 573 | ||
| 493 | 574 | it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => |
| 494 | 575 | Effect.gen(function* () { |
| 495 | 576 | const cached = new TokenStore.RemoteDpopAccessToken({ |
packages/client-runtime/src/authorization/service.ts-1| ... | ... | @@ -341,7 +341,6 @@ export const make = Effect.gen(function* () { |
| 341 | 341 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, |
| 342 | 342 | credential: bootstrap.credential, |
| 343 | 343 | dpopProof: bootstrapProof, |
| 344 | scopes: presentation.scopes, | |
| 345 | 344 | clientMetadata: presentation.metadata, |
| 346 | 345 | }).pipe( |
| 347 | 346 | Effect.mapError(mapRemoteDpopEnvironmentError), |
packages/client-runtime/src/connection/onboarding.test.ts+64-3| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { |
| 2 | AuthAdministrativeScopes, | |
| 2 | 3 | AuthStandardClientScopes, |
| 3 | 4 | EnvironmentId, |
| 5 | type AuthEnvironmentScope, | |
| 4 | 6 | ORCHESTRATION_PROTOCOL_VERSION, |
| 5 | 7 | } from "@t3tools/contracts"; |
| 6 | 8 | import { describe, expect, it } from "@effect/vitest"; |
| ... | ... | @@ -10,6 +12,7 @@ import * as Option from "effect/Option"; |
| 10 | 12 | |
| 11 | 13 | import * as RpcHttp from "../rpc/http.ts"; |
| 12 | 14 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 15 | import { fetchRemoteSessionState } from "../authorization/remote.ts"; | |
| 13 | 16 | import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; |
| 14 | 17 | import { BearerConnectionTarget } from "./model.ts"; |
| 15 | 18 | import { |
| ... | ... | @@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed( |
| 26 | 29 | deviceType: "desktop", |
| 27 | 30 | os: "Test OS", |
| 28 | 31 | }, |
| 29 | scopes: AuthStandardClientScopes, | |
| 30 | 32 | }), |
| 31 | 33 | ); |
| 32 | 34 | |
| ... | ... | @@ -36,8 +38,11 @@ function layerPairingHttp( |
| 36 | 38 | readonly failDescriptor?: boolean; |
| 37 | 39 | readonly protocolVersion?: number; |
| 38 | 40 | readonly selfUpdate?: boolean; |
| 41 | readonly grantScopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 39 | 42 | }, |
| 40 | 43 | ) { |
| 44 | const grantScopes = options?.grantScopes ?? AuthStandardClientScopes; | |
| 45 | let sessionScopes: ReadonlyArray<string> = []; | |
| 41 | 46 | const fetchFn = ((input, init = {}) => { |
| 42 | 47 | const url = String(input); |
| 43 | 48 | calls.push({ url, init }); |
| ... | ... | @@ -67,13 +72,46 @@ function layerPairingHttp( |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | 74 | if (url.endsWith("/oauth/token")) { |
| 75 | const body = | |
| 76 | init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init.body); | |
| 77 | const requestedScope = new URLSearchParams(body).get("scope"); | |
| 78 | sessionScopes = requestedScope === null ? grantScopes : requestedScope.split(" "); | |
| 79 | if (!sessionScopes.every((scope) => grantScopes.some((granted) => granted === scope))) { | |
| 80 | return Promise.resolve( | |
| 81 | Response.json( | |
| 82 | { | |
| 83 | _tag: "EnvironmentRequestInvalidError", | |
| 84 | code: "invalid_request", | |
| 85 | reason: "scope_not_granted", | |
| 86 | traceId: "pairing-scope-test", | |
| 87 | }, | |
| 88 | { status: 400 }, | |
| 89 | ), | |
| 90 | ); | |
| 91 | } | |
| 70 | 92 | return Promise.resolve( |
| 71 | 93 | Response.json({ |
| 72 | 94 | access_token: "bearer-token", |
| 73 | 95 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 74 | 96 | token_type: "Bearer", |
| 75 | 97 | expires_in: 3600, |
| 76 | scope: AuthStandardClientScopes.join(" "), | |
| 98 | scope: sessionScopes.join(" "), | |
| 99 | }), | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | if (url.endsWith("/api/auth/session")) { | |
| 104 | return Promise.resolve( | |
| 105 | Response.json({ | |
| 106 | authenticated: true, | |
| 107 | auth: { | |
| 108 | policy: "remote-reachable", | |
| 109 | bootstrapMethods: ["one-time-token"], | |
| 110 | sessionMethods: ["bearer-access-token"], | |
| 111 | sessionCookieName: "t3_session", | |
| 112 | }, | |
| 113 | scopes: sessionScopes, | |
| 114 | sessionMethod: "bearer-access-token", | |
| 77 | 115 | }), |
| 78 | 116 | ); |
| 79 | 117 | } |
| ... | ... | @@ -123,8 +161,10 @@ describe("connection onboarding", () => { |
| 123 | 161 | : String(tokenRequest?.init.body); |
| 124 | 162 | const tokenParams = new URLSearchParams(tokenBody); |
| 125 | 163 | expect(tokenParams.get("subject_token")).toBe("pairing-token"); |
| 126 | expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); | |
| 164 | expect(tokenParams.has("scope")).toBe(false); | |
| 127 | 165 | expect(tokenParams.get("client_label")).toBe("T3 Code Test"); |
| 166 | expect(tokenParams.get("client_device_type")).toBe("desktop"); | |
| 167 | expect(tokenParams.get("client_os")).toBe("Test OS"); | |
| 128 | 168 | }), |
| 129 | 169 | ); |
| 130 | 170 | |
| ... | ... | @@ -149,6 +189,27 @@ describe("connection onboarding", () => { |
| 149 | 189 | ]); |
| 150 | 190 | }), |
| 151 | 191 | ); |
| 192 | it.effect.each([ | |
| 193 | { label: "read-only", scopes: ["orchestration:read"] }, | |
| 194 | { label: "administrative", scopes: AuthAdministrativeScopes }, | |
| 195 | ] as const)("preserves the $label grant when pairing a remote environment", ({ scopes }) => | |
| 196 | Effect.gen(function* () { | |
| 197 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | |
| 198 | const httpLayer = layerPairingHttp(calls, { grantScopes: scopes }); | |
| 199 | const registration = yield* preparePairingRegistration({ | |
| 200 | host: "remote.example.test", | |
| 201 | pairingCode: "pairing-token", | |
| 202 | }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer))); | |
| 203 | ||
| 204 | const session = yield* fetchRemoteSessionState({ | |
| 205 | httpBaseUrl: registration.profile.httpBaseUrl, | |
| 206 | bearerToken: registration.credential.token, | |
| 207 | }).pipe(Effect.provide(httpLayer)); | |
| 208 | ||
| 209 | expect(session.authenticated).toBe(true); | |
| 210 | expect(session.scopes).toEqual(scopes); | |
| 211 | }), | |
| 212 | ); | |
| 152 | 213 | |
| 153 | 214 | it.effect("refuses to add a route that reaches a different machine, keeping the code", () => |
| 154 | 215 | Effect.gen(function* () { |
packages/client-runtime/src/connection/onboarding.ts-1| ... | ... | @@ -131,7 +131,6 @@ export const preparePairingRegistration = Effect.fn( |
| 131 | 131 | const access = yield* bootstrapRemoteBearerSession({ |
| 132 | 132 | httpBaseUrl: target.httpBaseUrl, |
| 133 | 133 | credential: target.credential, |
| 134 | scopes: presentation.scopes, | |
| 135 | 134 | clientMetadata: presentation.metadata, |
| 136 | 135 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); |
| 137 | 136 | const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl); |
packages/client-runtime/src/connection/registry.test.ts+82-1| ... | ... | @@ -382,10 +382,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 382 | 382 | const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => |
| 383 | 383 | Effect.gen(function* () { |
| 384 | 384 | const target = route.target; |
| 385 | const prepared = { | |
| 385 | const credential = | |
| 386 | target._tag === "BearerConnectionTarget" | |
| 387 | ? yield* credentialStore.get(target.connectionId) | |
| 388 | : Option.none(); | |
| 389 | const prepared: PreparedConnection = { | |
| 386 | 390 | ...PREPARED, |
| 387 | 391 | environmentId: target.environmentId, |
| 388 | 392 | label: target.label, |
| 393 | httpAuthorization: Option.isSome(credential) | |
| 394 | ? { _tag: "Bearer", token: credential.value.token } | |
| 395 | : null, | |
| 389 | 396 | target, |
| 390 | 397 | }; |
| 391 | 398 | if (options?.prepareError) return yield* options.prepareError; |
| ... | ... | @@ -1201,6 +1208,80 @@ describe("EnvironmentRegistry", () => { |
| 1201 | 1208 | }), |
| 1202 | 1209 | ); |
| 1203 | 1210 | |
| 1211 | it.effect("moves prepared credential observers when re-pairing an unchanged environment", () => | |
| 1212 | Effect.gen(function* () { | |
| 1213 | const replacementCredential = new BearerConnectionCredential({ token: "replacement-token" }); | |
| 1214 | const harness = yield* makeHarness( | |
| 1215 | [BEARER_TARGET], | |
| 1216 | [BEARER_PROFILE], | |
| 1217 | [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], | |
| 1218 | ); | |
| 1219 | ||
| 1220 | yield* Effect.gen(function* () { | |
| 1221 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 1222 | const initialObserved = yield* Deferred.make<void>(); | |
| 1223 | const replacementObserved = yield* Deferred.make<void>(); | |
| 1224 | const tokens = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 1225 | const followedSupervisors = yield* Ref.make(0); | |
| 1226 | yield* registry.start; | |
| 1227 | ||
| 1228 | const subscription = yield* Effect.forkChild( | |
| 1229 | registry | |
| 1230 | .followStream( | |
| 1231 | BEARER_TARGET.environmentId, | |
| 1232 | Stream.unwrap( | |
| 1233 | Effect.gen(function* () { | |
| 1234 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | |
| 1235 | yield* Ref.update(followedSupervisors, (count) => count + 1); | |
| 1236 | return SubscriptionRef.changes(supervisor.prepared); | |
| 1237 | }), | |
| 1238 | ), | |
| 1239 | ) | |
| 1240 | .pipe( | |
| 1241 | Stream.filterMap((prepared) => | |
| 1242 | Option.isSome(prepared) && prepared.value.httpAuthorization?._tag === "Bearer" | |
| 1243 | ? Result.succeed(prepared.value.httpAuthorization.token) | |
| 1244 | : Result.failVoid, | |
| 1245 | ), | |
| 1246 | Stream.changes, | |
| 1247 | Stream.runForEach((token) => | |
| 1248 | Effect.gen(function* () { | |
| 1249 | yield* Ref.update(tokens, (current) => [...current, token]); | |
| 1250 | yield* Deferred.succeed( | |
| 1251 | token === replacementCredential.token ? replacementObserved : initialObserved, | |
| 1252 | undefined, | |
| 1253 | ); | |
| 1254 | }), | |
| 1255 | ), | |
| 1256 | ), | |
| 1257 | ); | |
| 1258 | ||
| 1259 | yield* Deferred.await(initialObserved); | |
| 1260 | yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); | |
| 1261 | yield* awaitConnectionState( | |
| 1262 | registry, | |
| 1263 | RELAY_TARGET.environmentId, | |
| 1264 | (state) => state.phase === "connected", | |
| 1265 | ); | |
| 1266 | yield* registry.register( | |
| 1267 | new BearerConnectionRegistration({ | |
| 1268 | target: new BearerConnectionTarget({ ...BEARER_TARGET }), | |
| 1269 | profile: new BearerConnectionProfile({ ...BEARER_PROFILE }), | |
| 1270 | credential: replacementCredential, | |
| 1271 | }), | |
| 1272 | ); | |
| 1273 | yield* Deferred.await(replacementObserved); | |
| 1274 | yield* Fiber.interrupt(subscription); | |
| 1275 | ||
| 1276 | expect(yield* Ref.get(tokens)).toEqual([ | |
| 1277 | BEARER_CREDENTIAL.token, | |
| 1278 | replacementCredential.token, | |
| 1279 | ]); | |
| 1280 | expect(yield* Ref.get(followedSupervisors)).toBe(2); | |
| 1281 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | |
| 1282 | }), | |
| 1283 | ); | |
| 1284 | ||
| 1204 | 1285 | it.effect("ignores retry signals for environments that are no longer registered", () => |
| 1205 | 1286 | Effect.gen(function* () { |
| 1206 | 1287 | const harness = yield* makeHarness([]); |
packages/client-runtime/src/connection/registry.ts+4-1| ... | ... | @@ -452,7 +452,10 @@ export const make = Effect.gen(function* () { |
| 452 | 452 | SubscriptionRef.changes(entries), |
| 453 | 453 | ).pipe( |
| 454 | 454 | Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), |
| 455 | Stream.changes, | |
| 455 | // Re-pairing can replace the supervisor while its catalog details stay unchanged. | |
| 456 | Stream.changesWith( | |
| 457 | (previous, current) => Option.getOrNull(previous) === Option.getOrNull(current), | |
| 458 | ), | |
| 456 | 459 | Stream.switchMap( |
| 457 | 460 | Option.match({ |
| 458 | 461 | onNone: () => Stream.empty, |
packages/client-runtime/src/connection/resolver.test.ts-1| ... | ... | @@ -176,7 +176,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 176 | 176 | ClientCapabilities.ClientPresentation, |
| 177 | 177 | ClientCapabilities.ClientPresentation.of({ |
| 178 | 178 | metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, |
| 179 | scopes: [], | |
| 180 | 179 | }), |
| 181 | 180 | ), |
| 182 | 181 | Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), |
packages/client-runtime/src/connection/supervisor.test.ts-1| ... | ... | @@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => { |
| 1572 | 1572 | }), |
| 1573 | 1573 | Layer.succeed(ClientCapabilities.ClientPresentation, { |
| 1574 | 1574 | metadata: { label: "Test client", deviceType: "desktop" }, |
| 1575 | scopes: AuthStandardClientScopes, | |
| 1576 | 1575 | }), |
| 1577 | 1576 | ), |
| 1578 | 1577 | ), |
packages/client-runtime/src/platform/capabilities.ts-2| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { |
| 2 | 2 | type AuthClientPresentationMetadata, |
| 3 | type AuthEnvironmentScope, | |
| 4 | 3 | type DesktopSshEnvironmentBootstrap, |
| 5 | 4 | type DesktopSshEnvironmentTarget, |
| 6 | 5 | EnvironmentId, |
| ... | ... | @@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service< |
| 45 | 44 | ClientPresentation, |
| 46 | 45 | { |
| 47 | 46 | readonly metadata: AuthClientPresentationMetadata; |
| 48 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 49 | 47 | } |
| 50 | 48 | >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} |
| 51 | 49 |