| 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,7 +10,6 @@ import { |
| 10 | Wakeups, | 10 | Wakeups, |
| 11 | } from "@t3tools/client-runtime/connection"; | 11 | } from "@t3tools/client-runtime/connection"; |
| 12 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; | 12 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| 13 | import { AuthStandardClientScopes } from "@t3tools/contracts"; | ||
| 14 | import * as Context from "effect/Context"; | 13 | import * as Context from "effect/Context"; |
| 15 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| ... | @@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext( | ... | @@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext( |
| 216 | ClientCapabilities.ClientPresentation, | 215 | ClientCapabilities.ClientPresentation, |
| 217 | ClientCapabilities.ClientPresentation.of({ | 216 | ClientCapabilities.ClientPresentation.of({ |
| 218 | metadata: authClientMetadata(Constants.expoConfig?.version), | 217 | metadata: authClientMetadata(Constants.expoConfig?.version), |
| 219 | scopes: AuthStandardClientScopes, | ||
| 220 | }), | 218 | }), |
| 221 | ), | 219 | ), |
| 222 | Context.add( | 220 | Context.add( |
apps/server/src/auth/EnvironmentAuth.test.ts+52-3| ... | @@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 353 | }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), | 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 | Effect.gen(function* () { | 357 | Effect.gen(function* () { |
| 358 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 358 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 359 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | 359 | const pairingCredential = yield* serverAuth.issuePairingCredential(); |
| ... | @@ -367,10 +367,40 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -367,10 +367,40 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 367 | .pipe(Effect.flip); | 367 | .pipe(Effect.flip); |
| 368 | 368 | ||
| 369 | expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); | 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 | }).pipe(Effect.provide(layerEnvironmentAuth())), | 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 | Effect.gen(function* () { | 404 | Effect.gen(function* () { |
| 375 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 405 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 376 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | 406 | const pairingCredential = yield* serverAuth.issuePairingCredential({ |
| ... | @@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 379 | 409 | ||
| 380 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | 410 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( |
| 381 | pairingCredential.credential, | 411 | pairingCredential.credential, |
| 382 | undefined, | 412 | requestedScopes, |
| 383 | requestMetadata, | 413 | requestMetadata, |
| 384 | ); | 414 | ); |
| 385 | 415 | ||
| ... | @@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 387 | }).pipe(Effect.provide(layerEnvironmentAuth())), | 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 | it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => | 439 | it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => |
| 391 | Effect.gen(function* () { | 440 | Effect.gen(function* () { |
| 392 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 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,6 +466,7 @@ export class EnvironmentAuth extends Context.Service< |
| 466 | readonly createBrowserSession: ( | 466 | readonly createBrowserSession: ( |
| 467 | credential: string, | 467 | credential: string, |
| 468 | requestMetadata: AuthClientMetadata, | 468 | requestMetadata: AuthClientMetadata, |
| 469 | previousSessionToken?: string, | ||
| 469 | ) => Effect.Effect< | 470 | ) => Effect.Effect< |
| 470 | { | 471 | { |
| 471 | readonly response: AuthBrowserSessionResult; | 472 | readonly response: AuthBrowserSessionResult; |
| ... | @@ -786,12 +787,11 @@ export const make = Effect.gen(function* () { | ... | @@ -786,12 +787,11 @@ export const make = Effect.gen(function* () { |
| 786 | Effect.withSpan("EnvironmentAuth.getSessionState"), | 787 | Effect.withSpan("EnvironmentAuth.getSessionState"), |
| 787 | ); | 788 | ); |
| 788 | 789 | ||
| 789 | const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( | 790 | const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn( |
| 790 | credential, | 791 | "EnvironmentAuth.createBrowserSession", |
| 791 | requestMetadata, | 792 | )(function* (credential, requestMetadata, previousSessionToken) { |
| 792 | ) => { | ||
| 793 | if (devAuth?.matches(credential)) { | 793 | if (devAuth?.matches(credential)) { |
| 794 | return sessions.verify(credential).pipe( | 794 | return yield* sessions.verify(credential).pipe( |
| 795 | mapSessionVerificationErrors, | 795 | mapSessionVerificationErrors, |
| 796 | Effect.flatMap((session) => | 796 | Effect.flatMap((session) => |
| 797 | DateTime.now.pipe( | 797 | DateTime.now.pipe( |
| ... | @@ -814,38 +814,40 @@ export const make = Effect.gen(function* () { | ... | @@ -814,38 +814,40 @@ export const make = Effect.gen(function* () { |
| 814 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), | 814 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), |
| 815 | ); | 815 | ); |
| 816 | } | 816 | } |
| 817 | return bootstrapCredentials.consume(credential).pipe( | 817 | const previousSession = |
| 818 | Effect.mapError(toBootstrapExchangeError), | 818 | previousSessionToken === undefined |
| 819 | Effect.flatMap((grant) => | 819 | ? undefined |
| 820 | sessions | 820 | : yield* sessions.verify(previousSessionToken).pipe( |
| 821 | .issue({ | 821 | Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void), |
| 822 | method: "browser-session-cookie", | 822 | Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })), |
| 823 | subject: grant.subject, | 823 | ); |
| 824 | scopes: grant.scopes, | 824 | const grant = yield* bootstrapCredentials |
| 825 | client: { | 825 | .consume(credential) |
| 826 | ...requestMetadata, | 826 | .pipe(Effect.mapError(toBootstrapExchangeError)); |
| 827 | ...(grant.label ? { label: grant.label } : {}), | 827 | const session = yield* sessions |
| 828 | }, | 828 | .issue({ |
| 829 | }) | 829 | method: "browser-session-cookie", |
| 830 | .pipe( | 830 | subject: grant.subject, |
| 831 | Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), | 831 | scopes: grant.scopes, |
| 832 | ), | 832 | ...(previousSession?.method === "browser-session-cookie" |
| 833 | ), | 833 | ? { replaceSessionId: previousSession.sessionId } |
| 834 | Effect.map( | 834 | : {}), |
| 835 | (session) => | 835 | client: { |
| 836 | ({ | 836 | ...requestMetadata, |
| 837 | response: { | 837 | ...(grant.label ? { label: grant.label } : {}), |
| 838 | authenticated: true, | 838 | }, |
| 839 | scopes: session.scopes, | 839 | }) |
| 840 | sessionMethod: session.method, | 840 | .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause }))); |
| 841 | expiresAt: DateTime.toUtc(session.expiresAt), | 841 | return { |
| 842 | } satisfies AuthBrowserSessionResult, | 842 | response: { |
| 843 | sessionToken: session.token, | 843 | authenticated: true, |
| 844 | }) satisfies BootstrapExchangeResult, | 844 | scopes: session.scopes, |
| 845 | ), | 845 | sessionMethod: session.method, |
| 846 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), | 846 | expiresAt: DateTime.toUtc(session.expiresAt), |
| 847 | ); | 847 | } satisfies AuthBrowserSessionResult, |
| 848 | }; | 848 | sessionToken: session.token, |
| 849 | } satisfies BootstrapExchangeResult; | ||
| 850 | }); | ||
| 849 | 851 | ||
| 850 | type ResolvedBootstrapGrant = Pick< | 852 | type ResolvedBootstrapGrant = Pick< |
| 851 | PairingGrantStore.BootstrapGrant, | 853 | PairingGrantStore.BootstrapGrant, |
| ... | @@ -855,15 +857,24 @@ export const make = Effect.gen(function* () { | ... | @@ -855,15 +857,24 @@ export const make = Effect.gen(function* () { |
| 855 | }; | 857 | }; |
| 856 | const resolveBootstrapGrant = ( | 858 | const resolveBootstrapGrant = ( |
| 857 | credential: string, | 859 | credential: string, |
| 858 | input?: { readonly proofKeyThumbprint?: string }, | 860 | input?: { |
| 861 | readonly proofKeyThumbprint?: string; | ||
| 862 | readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 863 | }, | ||
| 859 | ): Effect.Effect< | 864 | ): Effect.Effect< |
| 860 | ResolvedBootstrapGrant, | 865 | ResolvedBootstrapGrant, |
| 861 | ServerAuthInvalidCredentialError | ServerAuthInternalError | 866 | ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError |
| 862 | > => { | 867 | > => { |
| 863 | if (!devAuth?.matches(credential)) { | 868 | if (!devAuth?.matches(credential)) { |
| 864 | return bootstrapCredentials | 869 | return bootstrapCredentials |
| 865 | .consume(credential, input) | 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 | return sessions.verify(credential).pipe( | 879 | return sessions.verify(credential).pipe( |
| 869 | mapSessionVerificationErrors, | 880 | mapSessionVerificationErrors, |
| ... | @@ -879,14 +890,15 @@ export const make = Effect.gen(function* () { | ... | @@ -879,14 +890,15 @@ export const make = Effect.gen(function* () { |
| 879 | }; | 890 | }; |
| 880 | 891 | ||
| 881 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = | 892 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = |
| 882 | (credential, requestedScopes, requestMetadata, input) => | 893 | (credential, requestedScopesInput, requestMetadata, input) => { |
| 883 | resolveBootstrapGrant(credential, input).pipe( | 894 | const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined; |
| 895 | return resolveBootstrapGrant(credential, { | ||
| 896 | ...input, | ||
| 897 | ...(requestedScopes !== undefined ? { requestedScopes } : {}), | ||
| 898 | }).pipe( | ||
| 884 | Effect.flatMap((grant) => | 899 | Effect.flatMap((grant) => |
| 885 | Effect.gen(function* () { | 900 | Effect.gen(function* () { |
| 886 | const grantedScopes = requestedScopes ?? grant.scopes; | 901 | const grantedScopes = requestedScopes ?? grant.scopes; |
| 887 | if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { | ||
| 888 | return yield* new ServerAuthScopeNotGrantedError({}); | ||
| 889 | } | ||
| 890 | return yield* sessions | 902 | return yield* sessions |
| 891 | .issue({ | 903 | .issue({ |
| 892 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", | 904 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", |
| ... | @@ -934,6 +946,7 @@ export const make = Effect.gen(function* () { | ... | @@ -934,6 +946,7 @@ export const make = Effect.gen(function* () { |
| 934 | ), | 946 | ), |
| 935 | Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), | 947 | Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), |
| 936 | ); | 948 | ); |
| 949 | }; | ||
| 937 | 950 | ||
| 938 | const issuePairingCredentialForSubject = (input: { | 951 | const issuePairingCredentialForSubject = (input: { |
| 939 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 952 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| ... | @@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () { | ... | @@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () { |
| 1238 | Effect.catchTags({ | 1251 | Effect.catchTags({ |
| 1239 | ServerAuthInvalidCredentialError: () => | 1252 | ServerAuthInvalidCredentialError: () => |
| 1240 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), | 1253 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), |
| 1254 | ServerAuthScopeNotGrantedError: () => | ||
| 1255 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), | ||
| 1241 | }), | 1256 | }), |
| 1242 | Effect.flatMap((grant) => | 1257 | Effect.flatMap((grant) => |
| 1243 | grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" | 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,7 +104,11 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 104 | const token = yield* bootstrapCredentials.issueOneTimeToken(); | 104 | const token = yield* bootstrapCredentials.issueOneTimeToken(); |
| 105 | const results = yield* Effect.all( | 105 | const results = yield* Effect.all( |
| 106 | Array.from({ length: 8 }, () => | 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 | concurrency: "unbounded", | 114 | concurrency: "unbounded", |
| ... | @@ -128,20 +132,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -128,20 +132,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 128 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 132 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 129 | const token = yield* bootstrapCredentials.issueOneTimeToken({ | 133 | const token = yield* bootstrapCredentials.issueOneTimeToken({ |
| 130 | proofKeyThumbprint: "client-proof-key-thumbprint", | 134 | proofKeyThumbprint: "client-proof-key-thumbprint", |
| 135 | scopes: ["orchestration:read"], | ||
| 131 | }); | 136 | }); |
| 132 | 137 | ||
| 133 | const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); | 138 | const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); |
| 134 | const wrong = yield* Effect.flip( | 139 | const wrong = yield* Effect.flip( |
| 135 | bootstrapCredentials.consume(token.credential, { | 140 | bootstrapCredentials.consume(token.credential, { |
| 136 | proofKeyThumbprint: "other-proof-key-thumbprint", | 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 | const consumed = yield* bootstrapCredentials.consume(token.credential, { | 151 | const consumed = yield* bootstrapCredentials.consume(token.credential, { |
| 140 | proofKeyThumbprint: "client-proof-key-thumbprint", | 152 | proofKeyThumbprint: "client-proof-key-thumbprint", |
| 153 | requestedScopes: ["orchestration:read"], | ||
| 141 | }); | 154 | }); |
| 142 | 155 | ||
| 143 | expect(missing.message).toContain("proof key mismatch"); | 156 | expect(missing.message).toContain("proof key mismatch"); |
| 144 | expect(wrong.message).toContain("proof key mismatch"); | 157 | expect(wrong.message).toContain("proof key mismatch"); |
| 158 | expect(forbiddenScope._tag).toBe("BootstrapCredentialScopeNotGrantedError"); | ||
| 145 | expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); | 159 | expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); |
| 146 | }).pipe(Effect.provide(layerPairingGrantStore())), | 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,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 | export const BootstrapCredentialInvalidError = Schema.Union([ | 81 | export const BootstrapCredentialInvalidError = Schema.Union([ |
| 73 | UnknownBootstrapCredentialError, | 82 | UnknownBootstrapCredentialError, |
| 74 | ExpiredBootstrapCredentialError, | 83 | ExpiredBootstrapCredentialError, |
| ... | @@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI | ... | @@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI |
| 174 | export const BootstrapCredentialError = Schema.Union([ | 183 | export const BootstrapCredentialError = Schema.Union([ |
| 175 | BootstrapCredentialInvalidError, | 184 | BootstrapCredentialInvalidError, |
| 176 | BootstrapCredentialInternalError, | 185 | BootstrapCredentialInternalError, |
| 186 | BootstrapCredentialScopeNotGrantedError, | ||
| 177 | ]); | 187 | ]); |
| 178 | export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; | 188 | export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; |
| 179 | 189 | ||
| ... | @@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service< | ... | @@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service< |
| 220 | credential: string, | 230 | credential: string, |
| 221 | input?: { | 231 | input?: { |
| 222 | readonly proofKeyThumbprint?: string; | 232 | readonly proofKeyThumbprint?: string; |
| 233 | readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 223 | }, | 234 | }, |
| 224 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | 235 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; |
| 225 | } | 236 | } |
| ... | @@ -232,7 +243,7 @@ interface StoredBootstrapGrant extends BootstrapGrant { | ... | @@ -232,7 +243,7 @@ interface StoredBootstrapGrant extends BootstrapGrant { |
| 232 | type ConsumeResult = | 243 | type ConsumeResult = |
| 233 | | { | 244 | | { |
| 234 | readonly _tag: "error"; | 245 | readonly _tag: "error"; |
| 235 | readonly reason: "not-found" | "expired"; | 246 | readonly reason: "not-found" | "expired" | "scope-not-granted"; |
| 236 | readonly error: BootstrapCredentialError; | 247 | readonly error: BootstrapCredentialError; |
| 237 | } | 248 | } |
| 238 | | { | 249 | | { |
| ... | @@ -493,6 +504,17 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | const remainingUses = grant.remainingUses; | 518 | const remainingUses = grant.remainingUses; |
| 497 | if (typeof remainingUses === "number") { | 519 | if (typeof remainingUses === "number") { |
| 498 | if (remainingUses <= 1) { | 520 | if (remainingUses <= 1) { |
| ... | @@ -531,10 +553,16 @@ export const make = Effect.gen(function* () { | ... | @@ -531,10 +553,16 @@ export const make = Effect.gen(function* () { |
| 531 | return yield* seededResult.error; | 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 | const consumed = yield* pairingLinks | 559 | const consumed = yield* pairingLinks |
| 535 | .consumeAvailable({ | 560 | .consumeAvailable({ |
| 536 | credential, | 561 | credential, |
| 537 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, | 562 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, |
| 563 | ...(input?.requestedScopes !== undefined | ||
| 564 | ? { requestedScopes: input.requestedScopes } | ||
| 565 | : {}), | ||
| 538 | consumedAt: now, | 566 | consumedAt: now, |
| 539 | now, | 567 | now, |
| 540 | }) | 568 | }) |
| ... | @@ -580,6 +608,10 @@ export const make = Effect.gen(function* () { | ... | @@ -580,6 +608,10 @@ export const make = Effect.gen(function* () { |
| 580 | return yield* new BootstrapCredentialProofKeyMismatchError({}); | 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 | return yield* new UnavailableBootstrapCredentialError({}); | 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,6 +428,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 428 | }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), | 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 | it.effect("rejects websocket tokens once the parent session has expired", () => | 472 | it.effect("rejects websocket tokens once the parent session has expired", () => |
| 432 | Effect.gen(function* () { | 473 | Effect.gen(function* () { |
| 433 | const sessions = yield* SessionStore.SessionStore; | 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,6 +383,8 @@ export class SessionStore extends Context.Service< |
| 383 | * before storing this session. | 383 | * before storing this session. |
| 384 | */ | 384 | */ |
| 385 | readonly replaceActiveForSubjectAndMethod?: boolean; | 385 | readonly replaceActiveForSubjectAndMethod?: boolean; |
| 386 | /** Replace only this session, of the same method, in the issuance transaction. */ | ||
| 387 | readonly replaceSessionId?: AuthSessionId; | ||
| 386 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | 388 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 387 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | 389 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 388 | readonly issueWebSocketToken: ( | 390 | readonly issueWebSocketToken: ( |
| ... | @@ -708,8 +710,14 @@ export const make = Effect.gen(function* () { | ... | @@ -708,8 +710,14 @@ export const make = Effect.gen(function* () { |
| 708 | expiresAt, | 710 | expiresAt, |
| 709 | } satisfies AuthSessions.CreateAuthSessionInput; | 711 | } satisfies AuthSessions.CreateAuthSessionInput; |
| 710 | const replacedSessionIds = yield* ( | 712 | const replacedSessionIds = yield* ( |
| 711 | input?.replaceActiveForSubjectAndMethod | 713 | input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod |
| 712 | ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) | 714 | ? authSessions.createReplacingActive({ |
| 715 | session: sessionRecord, | ||
| 716 | revokedAt: issuedAt, | ||
| 717 | ...(input.replaceSessionId !== undefined | ||
| 718 | ? { replaceSessionId: input.replaceSessionId } | ||
| 719 | : {}), | ||
| 720 | }) | ||
| 713 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) | 721 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) |
| 714 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); | 722 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); |
| 715 | if (replacedSessionIds.length > 0) { | 723 | if (replacedSessionIds.length > 0) { |
apps/server/src/auth/http.ts+9| ... | @@ -303,9 +303,18 @@ export const layer = HttpApiBuilder.group( | ... | @@ -303,9 +303,18 @@ export const layer = HttpApiBuilder.group( |
| 303 | function* (args) { | 303 | function* (args) { |
| 304 | yield* annotateEnvironmentRequest(args.endpoint.name); | 304 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 305 | const request = yield* HttpServerRequest.HttpServerRequest; | 305 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 306 | const previousCredential = EnvironmentAuth.selectRequestCredential( | ||
| 307 | request, | ||
| 308 | sessions.cookieName, | ||
| 309 | sessions.legacyCookieName, | ||
| 310 | ); | ||
| 306 | const result = yield* serverAuth.createBrowserSession( | 311 | const result = yield* serverAuth.createBrowserSession( |
| 307 | args.payload.credential, | 312 | args.payload.credential, |
| 308 | deriveAuthClientMetadata({ request }), | 313 | deriveAuthClientMetadata({ request }), |
| 314 | previousCredential?.source === "cookie" || | ||
| 315 | previousCredential?.source === "legacy-cookie" | ||
| 316 | ? previousCredential.token | ||
| 317 | : undefined, | ||
| 309 | ); | 318 | ); |
| 310 | const cookieName = result.cookieName ?? sessions.cookieName; | 319 | const cookieName = result.cookieName ?? sessions.cookieName; |
| 311 | const selectedCookie = yield* Effect.fromResult( | 320 | const selectedCookie = yield* Effect.fromResult( |
apps/server/src/cli/auth.ts+5-2| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | formatSessionList, | 19 | formatSessionList, |
| 20 | } from "../cliAuthFormat.ts"; | 20 | } from "../cliAuthFormat.ts"; |
| 21 | import * as ServerConfig from "../config.ts"; | 21 | import * as ServerConfig from "../config.ts"; |
| 22 | import { authScopesFlag } from "./authScopes.ts"; | ||
| 22 | import { | 23 | import { |
| 23 | authLocationFlags, | 24 | authLocationFlags, |
| 24 | type CliAuthLocationFlags, | 25 | type CliAuthLocationFlags, |
| ... | @@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.Boolean("token-only").pipe( | ... | @@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.Boolean("token-only").pipe( |
| 83 | 84 | ||
| 84 | const pairingCreateCommand = Command.make("create", { | 85 | const pairingCreateCommand = Command.make("create", { |
| 85 | ...authLocationFlags, | 86 | ...authLocationFlags, |
| 87 | scopes: authScopesFlag(AuthStandardClientScopes), | ||
| 86 | ttl: ttlFlag, | 88 | ttl: ttlFlag, |
| 87 | label: labelFlag, | 89 | label: labelFlag, |
| 88 | baseUrl: baseUrlFlag, | 90 | baseUrl: baseUrlFlag, |
| ... | @@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", { | ... | @@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", { |
| 95 | (environmentAuth) => | 97 | (environmentAuth) => |
| 96 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 97 | const issued = yield* environmentAuth.createPairingLink({ | 99 | const issued = yield* environmentAuth.createPairingLink({ |
| 98 | scopes: AuthStandardClientScopes, | 100 | scopes: flags.scopes, |
| 99 | subject: "one-time-token", | 101 | subject: "one-time-token", |
| 100 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | 102 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 101 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | 103 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| ... | @@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe( | ... | @@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe( |
| 161 | 163 | ||
| 162 | const sessionIssueCommand = Command.make("issue", { | 164 | const sessionIssueCommand = Command.make("issue", { |
| 163 | ...authLocationFlags, | 165 | ...authLocationFlags, |
| 166 | scopes: authScopesFlag(AuthAdministrativeScopes), | ||
| 164 | ttl: ttlFlag, | 167 | ttl: ttlFlag, |
| 165 | label: labelFlag, | 168 | label: labelFlag, |
| 166 | subject: subjectFlag, | 169 | subject: subjectFlag, |
| ... | @@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", { | ... | @@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", { |
| 174 | (environmentAuth) => | 177 | (environmentAuth) => |
| 175 | Effect.gen(function* () { | 178 | Effect.gen(function* () { |
| 176 | const issued = yield* environmentAuth.issueSession({ | 179 | const issued = yield* environmentAuth.issueSession({ |
| 177 | scopes: AuthAdministrativeScopes, | 180 | scopes: flags.scopes, |
| 178 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | 181 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 179 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | 182 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| 180 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | 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,13 +5,14 @@ import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; | 5 | import * as NodePath from "node:path"; |
| 6 | 6 | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { AuthStandardClientScopes } from "@t3tools/contracts"; | ||
| 8 | import * as NetService from "@t3tools/shared/Net"; | 9 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 10 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | import { assert, describe, expect, it } from "@effect/vitest"; | 11 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 13 | import * as TestConsole from "effect/testing/TestConsole"; | 14 | import * as TestConsole from "effect/testing/TestConsole"; |
| 14 | import { Command } from "effect/cli"; | 15 | import { Command, CliError } from "effect/cli"; |
| 15 | 16 | ||
| 16 | import { cli } from "../binCli.ts"; | 17 | import { cli } from "../binCli.ts"; |
| 17 | import { | 18 | import { |
| ... | @@ -173,9 +174,13 @@ describe("t3 pair", () => { | ... | @@ -173,9 +174,13 @@ describe("t3 pair", () => { |
| 173 | const listed = yield* captureStdout( | 174 | const listed = yield* captureStdout( |
| 174 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | 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 | assert.equal(credentials.length, 1); | 181 | assert.equal(credentials.length, 1); |
| 178 | assert.equal(credentials[0]?.label, "t3 pair"); | 182 | assert.equal(credentials[0]?.label, "t3 pair"); |
| 183 | assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes); | ||
| 179 | }), | 184 | }), |
| 180 | ).pipe( | 185 | ).pipe( |
| 181 | Effect.provide(NodeServices.layer), | 186 | Effect.provide(NodeServices.layer), |
| ... | @@ -195,6 +200,43 @@ describe("t3 pair", () => { | ... | @@ -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 | it.effect("pairs through the recorded dev web URL for dev servers", () => | 240 | it.effect("pairs through the recorded dev web URL for dev servers", () => |
| 199 | withDescriptorServer((origin) => | 241 | withDescriptorServer((origin) => |
| 200 | Effect.gen(function* () { | 242 | Effect.gen(function* () { |
| ... | @@ -288,3 +330,73 @@ describe("t3 pair", () => { | ... | @@ -288,3 +330,73 @@ describe("t3 pair", () => { |
| 288 | }).pipe(Effect.provide(NodeServices.layer)), | 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,6 +10,7 @@ |
| 10 | * HTTPS and pairs through the tailnet URL instead. | 10 | * HTTPS and pairs through the tailnet URL instead. |
| 11 | */ | 11 | */ |
| 12 | import { | 12 | import { |
| 13 | type AuthEnvironmentScope, | ||
| 13 | AuthStandardClientScopes, | 14 | AuthStandardClientScopes, |
| 14 | ExecutionEnvironmentDescriptor, | 15 | ExecutionEnvironmentDescriptor, |
| 15 | PortSchema, | 16 | PortSchema, |
| ... | @@ -51,6 +52,7 @@ import { | ... | @@ -51,6 +52,7 @@ import { |
| 51 | renderTerminalQrCode, | 52 | renderTerminalQrCode, |
| 52 | resolveHeadlessConnectionString, | 53 | resolveHeadlessConnectionString, |
| 53 | } from "../startupAccess.ts"; | 54 | } from "../startupAccess.ts"; |
| 55 | import { authScopesFlag } from "./authScopes.ts"; | ||
| 54 | import { baseDirFlag, DurationFromString } from "./config.ts"; | 56 | import { baseDirFlag, DurationFromString } from "./config.ts"; |
| 55 | 57 | ||
| 56 | const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; | 58 | const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; |
| ... | @@ -426,13 +428,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" | ... | @@ -426,13 +428,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" |
| 426 | 428 | ||
| 427 | const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { | 429 | const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { |
| 428 | readonly config: ServerConfig.ServerConfig["Service"]; | 430 | readonly config: ServerConfig.ServerConfig["Service"]; |
| 431 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | ||
| 429 | readonly ttl: Option.Option<Duration.Duration>; | 432 | readonly ttl: Option.Option<Duration.Duration>; |
| 430 | readonly label: Option.Option<string>; | 433 | readonly label: Option.Option<string>; |
| 431 | }) { | 434 | }) { |
| 432 | return yield* Effect.gen(function* () { | 435 | return yield* Effect.gen(function* () { |
| 433 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | 436 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 434 | return yield* environmentAuth.createPairingLink({ | 437 | return yield* environmentAuth.createPairingLink({ |
| 435 | scopes: AuthStandardClientScopes, | 438 | scopes: input.scopes, |
| 436 | subject: "one-time-token", | 439 | subject: "one-time-token", |
| 437 | label: Option.getOrElse(input.label, () => "t3 pair"), | 440 | label: Option.getOrElse(input.label, () => "t3 pair"), |
| 438 | ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), | 441 | ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), |
| ... | @@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( | ... | @@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( |
| 475 | 478 | ||
| 476 | export const pairCommand = Command.make("pair", { | 479 | export const pairCommand = Command.make("pair", { |
| 477 | baseDir: baseDirFlag, | 480 | baseDir: baseDirFlag, |
| 481 | scopes: authScopesFlag(AuthStandardClientScopes), | ||
| 478 | ttl: ttlFlag, | 482 | ttl: ttlFlag, |
| 479 | label: labelFlag, | 483 | label: labelFlag, |
| 480 | tailscale: tailscaleFlag, | 484 | tailscale: tailscaleFlag, |
| ... | @@ -516,7 +520,12 @@ export const pairCommand = Command.make("pair", { | ... | @@ -516,7 +520,12 @@ export const pairCommand = Command.make("pair", { |
| 516 | } | 520 | } |
| 517 | 521 | ||
| 518 | const config = yield* makePairServerConfig({ target, logLevel }); | 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 | const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); | 529 | const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); |
| 521 | 530 | ||
| 522 | yield* Console.log( | 531 | yield* Console.log( |
apps/server/src/persistence/AuthPairingLinks.ts+9-1| ... | @@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; | ... | @@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; |
| 46 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ | 46 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ |
| 47 | credential: Schema.String, | 47 | credential: Schema.String, |
| 48 | proofKeyThumbprint: Schema.NullOr(Schema.String), | 48 | proofKeyThumbprint: Schema.NullOr(Schema.String), |
| 49 | requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), | ||
| 49 | consumedAt: Schema.DateTimeUtcFromString, | 50 | consumedAt: Schema.DateTimeUtcFromString, |
| 50 | now: Schema.DateTimeUtcFromString, | 51 | now: Schema.DateTimeUtcFromString, |
| 51 | }); | 52 | }); |
| ... | @@ -159,7 +160,7 @@ export const make = Effect.gen(function* () { | ... | @@ -159,7 +160,7 @@ export const make = Effect.gen(function* () { |
| 159 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ | 160 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ |
| 160 | Request: ConsumeAuthPairingLinkInput, | 161 | Request: ConsumeAuthPairingLinkInput, |
| 161 | Result: AuthPairingLinkRawDbRow, | 162 | Result: AuthPairingLinkRawDbRow, |
| 162 | execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => | 163 | execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => |
| 163 | sql` | 164 | sql` |
| 164 | UPDATE auth_pairing_links | 165 | UPDATE auth_pairing_links |
| 165 | SET consumed_at = ${consumedAt} | 166 | SET consumed_at = ${consumedAt} |
| ... | @@ -171,6 +172,13 @@ export const make = Effect.gen(function* () { | ... | @@ -171,6 +172,13 @@ export const make = Effect.gen(function* () { |
| 171 | proof_key_thumbprint IS NULL | 172 | proof_key_thumbprint IS NULL |
| 172 | OR proof_key_thumbprint = ${proofKeyThumbprint} | 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 | RETURNING | 182 | RETURNING |
| 175 | id AS "id", | 183 | id AS "id", |
| 176 | credential AS "credential", | 184 | credential AS "credential", |
apps/server/src/persistence/AuthSessions.ts+7-2| ... | @@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; | ... | @@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; |
| 58 | export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ | 58 | export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ |
| 59 | session: CreateAuthSessionInput, | 59 | session: CreateAuthSessionInput, |
| 60 | revokedAt: Schema.DateTimeUtcFromString, | 60 | revokedAt: Schema.DateTimeUtcFromString, |
| 61 | replaceSessionId: Schema.optionalKey(AuthSessionId), | ||
| 61 | }); | 62 | }); |
| 62 | export type CreateReplacingActiveAuthSessionInput = | 63 | export type CreateReplacingActiveAuthSessionInput = |
| 63 | typeof CreateReplacingActiveAuthSessionInput.Type; | 64 | typeof CreateReplacingActiveAuthSessionInput.Type; |
| ... | @@ -276,11 +277,15 @@ export const make = Effect.gen(function* () { | ... | @@ -276,11 +277,15 @@ export const make = Effect.gen(function* () { |
| 276 | const revokeActiveSessionsForReplacement = SqlSchema.findAll({ | 277 | const revokeActiveSessionsForReplacement = SqlSchema.findAll({ |
| 277 | Request: CreateReplacingActiveAuthSessionInput, | 278 | Request: CreateReplacingActiveAuthSessionInput, |
| 278 | Result: Schema.Struct({ sessionId: AuthSessionId }), | 279 | Result: Schema.Struct({ sessionId: AuthSessionId }), |
| 279 | execute: ({ session, revokedAt }) => | 280 | execute: ({ session, revokedAt, replaceSessionId }) => |
| 280 | sql` | 281 | sql` |
| 281 | UPDATE auth_sessions | 282 | UPDATE auth_sessions |
| 282 | SET revoked_at = ${revokedAt} | 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 | AND method = ${session.method} | 289 | AND method = ${session.method} |
| 285 | AND revoked_at IS NULL | 290 | AND revoked_at IS NULL |
| 286 | AND expires_at > ${revokedAt} | 291 | AND expires_at > ${revokedAt} |
apps/web/src/authBootstrap.test.ts+153-6| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type AuthSessionState, | 5 | type AuthSessionState, |
| 6 | type DesktopBridge, | 6 | type DesktopBridge, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { createBrowserHistory } from "@tanstack/react-router"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | 9 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; | 11 | import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| ... | @@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./ | ... | @@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./ |
| 16 | type TestWindow = { | 17 | type TestWindow = { |
| 17 | location: URL; | 18 | location: URL; |
| 18 | history: { | 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 | desktopBridge?: DesktopBridge; | 23 | desktopBridge?: DesktopBridge; |
| 22 | }; | 24 | }; |
| ... | @@ -59,8 +61,12 @@ function installTestBrowser(url: string) { | ... | @@ -59,8 +61,12 @@ function installTestBrowser(url: string) { |
| 59 | const testWindow: TestWindow = { | 61 | const testWindow: TestWindow = { |
| 60 | location: new URL(url), | 62 | location: new URL(url), |
| 61 | history: { | 63 | history: { |
| 62 | replaceState: (_data, _unused, nextUrl) => { | 64 | state: null, |
| 63 | testWindow.location = new URL(nextUrl, testWindow.location.href); | 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,6 +97,14 @@ function sequence<A>(...values: ReadonlyArray<A>) { |
| 91 | return () => values[Math.min(index++, values.length - 1)]!; | 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 | let disposeHttpTest: (() => Promise<void>) | undefined; | 108 | let disposeHttpTest: (() => Promise<void>) | undefined; |
| 95 | 109 | ||
| 96 | async function installAuthApi(input: { | 110 | async function installAuthApi(input: { |
| ... | @@ -286,6 +300,139 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -286,6 +300,139 @@ describe("resolveInitialServerAuthGateState", () => { |
| 286 | expect(testWindow.location.searchParams.get("token")).toBeNull(); | 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 | it("allows manual token submission after the initial auth check requires pairing", async () => { | 436 | it("allows manual token submission after the initial auth check requires pairing", async () => { |
| 290 | const nextSession = sequence( | 437 | const nextSession = sequence( |
| 291 | unauthenticatedSession(LOOPBACK_AUTH), | 438 | unauthenticatedSession(LOOPBACK_AUTH), |
| ... | @@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 522 | expect(testWindow.location.hash).toBe(""); | 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 | const testApi = await installAuthApi({ | 673 | const testApi = await installAuthApi({ |
| 527 | session: () => authenticatedSession(LOOPBACK_AUTH), | 674 | session: () => authenticatedSession(LOOPBACK_AUTH), |
| 528 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), | 675 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| ... | @@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 533 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 680 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 534 | status: "authenticated", | 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 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); | 685 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); |
| 539 | 686 | ||
| ... | @@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 575 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 722 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 576 | 723 | ||
| 577 | const initialBootstrap = resolveInitialServerAuthGateState(); | 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 | const explicitPairing = resolveInitialServerAuthGateState(); | 726 | const explicitPairing = resolveInitialServerAuthGateState(); |
| 580 | const laterCaller = resolveInitialServerAuthGateState(); | 727 | const laterCaller = resolveInitialServerAuthGateState(); |
| 581 | let laterCallerSettled = false; | 728 | let laterCallerSettled = false; |
apps/web/src/connection/platform.ts+2-1| ... | @@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext( | ... | @@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext( |
| 228 | Effect.sync(() => { | 228 | Effect.sync(() => { |
| 229 | const presentation = ClientCapabilities.ClientPresentation.of({ | 229 | const presentation = ClientCapabilities.ClientPresentation.of({ |
| 230 | metadata: clientMetadata(), | 230 | metadata: clientMetadata(), |
| 231 | scopes: AuthStandardClientScopes, | ||
| 232 | }); | 231 | }); |
| 233 | const cloudSession = ClientCapabilities.CloudSession.of({ | 232 | const cloudSession = ClientCapabilities.CloudSession.of({ |
| 234 | identity: Effect.sync(() => | 233 | identity: Effect.sync(() => |
| ... | @@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn( | ... | @@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn( |
| 383 | Effect.mapError(mapRemoteEnvironmentError), | 382 | Effect.mapError(mapRemoteEnvironmentError), |
| 384 | ); | 383 | ); |
| 385 | const issuedAtEpochMs = yield* Clock.currentTimeMillis; | 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 | const access = yield* bootstrapRemoteBearerSession({ | 387 | const access = yield* bootstrapRemoteBearerSession({ |
| 387 | httpBaseUrl, | 388 | httpBaseUrl, |
| 388 | credential: entry.bootstrapToken, | 389 | credential: entry.bootstrapToken, |
apps/web/src/environments/primary/auth.ts+16| ... | @@ -140,6 +140,7 @@ type ServerAuthGateState = | ... | @@ -140,6 +140,7 @@ type ServerAuthGateState = |
| 140 | 140 | ||
| 141 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; | 141 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; |
| 142 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; | 142 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; |
| 143 | let explicitPairingRequested = false; | ||
| 143 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; | 144 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; |
| 144 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; | 145 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; |
| 145 | 146 | ||
| ... | @@ -347,6 +348,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo | ... | @@ -347,6 +348,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 347 | await waitForAuthenticatedSessionAfterBootstrap(); | 348 | await waitForAuthenticatedSessionAfterBootstrap(); |
| 348 | resolvedAuthenticatedGateState = { status: "authenticated" }; | 349 | resolvedAuthenticatedGateState = { status: "authenticated" }; |
| 349 | bootstrapPromise = null; | 350 | bootstrapPromise = null; |
| 351 | explicitPairingRequested = false; | ||
| 350 | stripPairingTokenFromUrl(); | 352 | stripPairingTokenFromUrl(); |
| 351 | } | 353 | } |
| 352 | 354 | ||
| ... | @@ -428,6 +430,19 @@ export async function revokeOtherServerClientSessions(): Promise<number> { | ... | @@ -428,6 +430,19 @@ export async function revokeOtherServerClientSessions(): Promise<number> { |
| 428 | } | 430 | } |
| 429 | 431 | ||
| 430 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { | 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 | const urlCredential = takePairingTokenFromUrl(); | 446 | const urlCredential = takePairingTokenFromUrl(); |
| 432 | const previousPromise = bootstrapPromise; | 447 | const previousPromise = bootstrapPromise; |
| 433 | if (urlCredential) { | 448 | if (urlCredential) { |
| ... | @@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat | ... | @@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat |
| 468 | export function __resetServerAuthBootstrapForTests() { | 483 | export function __resetServerAuthBootstrapForTests() { |
| 469 | bootstrapPromise = null; | 484 | bootstrapPromise = null; |
| 470 | resolvedAuthenticatedGateState = null; | 485 | resolvedAuthenticatedGateState = null; |
| 486 | explicitPairingRequested = false; | ||
| 471 | } | 487 | } |
apps/web/src/routes/pair.tsx+7-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import { | 3 | import { |
| 4 | HostedPairingRouteSurface, | 4 | HostedPairingRouteSurface, |
| ... | @@ -27,8 +27,8 @@ export const Route = createFileRoute("/pair")({ | ... | @@ -27,8 +27,8 @@ export const Route = createFileRoute("/pair")({ |
| 27 | }); | 27 | }); |
| 28 | 28 | ||
| 29 | function PairRouteView() { | 29 | function PairRouteView() { |
| 30 | const router = useRouter(); | ||
| 30 | const { authGateState } = Route.useRouteContext(); | 31 | const { authGateState } = Route.useRouteContext(); |
| 31 | const navigate = useNavigate(); | ||
| 32 | 32 | ||
| 33 | if (!authGateState) { | 33 | if (!authGateState) { |
| 34 | return null; | 34 | return null; |
| ... | @@ -42,7 +42,11 @@ function PairRouteView() { | ... | @@ -42,7 +42,11 @@ function PairRouteView() { |
| 42 | <PairingRouteSurface | 42 | <PairingRouteSurface |
| 43 | auth={authGateState.auth} | 43 | auth={authGateState.auth} |
| 44 | onAuthenticated={() => { | 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 | {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} | 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,6 +258,25 @@ management is available through `t3 auth --help`. |
| 258 | A session with an open connection stays listed after its access credential | 258 | A session with an open connection stays listed after its access credential |
| 259 | expires. | 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 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** | 280 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 262 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This | 281 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 263 | revokes its cloud access and frees its host space even when the environment is | 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,4 +1,8 @@ |
| 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | 1 | import { |
| 2 | AuthAdministrativeScopes, | ||
| 3 | AuthStandardClientScopes, | ||
| 4 | EnvironmentId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { | 6 | import { |
| 3 | RelayEnvironmentConnectScope, | 7 | RelayEnvironmentConnectScope, |
| 4 | type RelayEnvironmentConnectResponse, | 8 | type RelayEnvironmentConnectResponse, |
| ... | @@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = { | ... | @@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = { |
| 46 | expiresAt: "2026-06-06T01:00:00.000Z", | 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 | const calls: Array<readonly [RequestInfo | URL, RequestInit]> = []; | 56 | const calls: Array<readonly [RequestInfo | URL, RequestInit]> = []; |
| 51 | let responseIndex = 0; | 57 | let responseIndex = 0; |
| 52 | const fetchFn = ((input, init) => { | 58 | const fetchFn = ((input, init) => { |
| ... | @@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray<Response>) { | ... | @@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray<Response>) { |
| 54 | const response = responses[responseIndex++]; | 60 | const response = responses[responseIndex++]; |
| 55 | return response === undefined | 61 | return response === undefined |
| 56 | ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) | 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 | }) satisfies typeof fetch; | 64 | }) satisfies typeof fetch; |
| 59 | return { calls, fetchFn }; | 65 | return { calls, fetchFn }; |
| 60 | } | 66 | } |
| ... | @@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) => | ... | @@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) => |
| 65 | expiresAt: "2026-06-06T01:00:00.000Z", | 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 | Response.json({ | 75 | Response.json({ |
| 70 | access_token: token, | 76 | access_token: token, |
| 71 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | 77 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 72 | token_type: "DPoP", | 78 | token_type: "DPoP", |
| 73 | expires_in: 3_600, | 79 | expires_in: 3_600, |
| 74 | scope: AuthStandardClientScopes.join(" "), | 80 | scope, |
| 75 | }); | 81 | }); |
| 76 | 82 | ||
| 77 | const authInvalid = () => | 83 | const authInvalid = () => |
| ... | @@ -105,7 +111,7 @@ const persistedToken = ( | ... | @@ -105,7 +111,7 @@ const persistedToken = ( |
| 105 | 111 | ||
| 106 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { | 112 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { |
| 107 | readonly initialToken?: TokenStore.RemoteDpopAccessToken; | 113 | readonly initialToken?: TokenStore.RemoteDpopAccessToken; |
| 108 | readonly responses: ReadonlyArray<Response>; | 114 | readonly responses: ReadonlyArray<RecordedResponse>; |
| 109 | readonly bootstrap?: RelayEnvironmentConnectResponse; | 115 | readonly bootstrap?: RelayEnvironmentConnectResponse; |
| 110 | readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>; | 116 | readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>; |
| 111 | readonly beforePut?: Effect.Effect<void>; | 117 | readonly beforePut?: Effect.Effect<void>; |
| ... | @@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( | ... | @@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 210 | deviceType: "mobile", | 216 | deviceType: "mobile", |
| 211 | os: "test", | 217 | os: "test", |
| 212 | }, | 218 | }, |
| 213 | scopes: AuthStandardClientScopes, | ||
| 214 | }), | 219 | }), |
| 215 | ), | 220 | ), |
| 216 | ), | 221 | ), |
| ... | @@ -490,6 +495,82 @@ describe("RemoteEnvironmentAuthorization", () => { | ... | @@ -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 | it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => | 574 | it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => |
| 494 | Effect.gen(function* () { | 575 | Effect.gen(function* () { |
| 495 | const cached = new TokenStore.RemoteDpopAccessToken({ | 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,7 +341,6 @@ export const make = Effect.gen(function* () { |
| 341 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | 341 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, |
| 342 | credential: bootstrap.credential, | 342 | credential: bootstrap.credential, |
| 343 | dpopProof: bootstrapProof, | 343 | dpopProof: bootstrapProof, |
| 344 | scopes: presentation.scopes, | ||
| 345 | clientMetadata: presentation.metadata, | 344 | clientMetadata: presentation.metadata, |
| 346 | }).pipe( | 345 | }).pipe( |
| 347 | Effect.mapError(mapRemoteDpopEnvironmentError), | 346 | Effect.mapError(mapRemoteDpopEnvironmentError), |
packages/client-runtime/src/connection/onboarding.test.ts+64-3| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthAdministrativeScopes, | ||
| 2 | AuthStandardClientScopes, | 3 | AuthStandardClientScopes, |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | type AuthEnvironmentScope, | ||
| 4 | ORCHESTRATION_PROTOCOL_VERSION, | 6 | ORCHESTRATION_PROTOCOL_VERSION, |
| 5 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| ... | @@ -10,6 +12,7 @@ import * as Option from "effect/Option"; | ... | @@ -10,6 +12,7 @@ import * as Option from "effect/Option"; |
| 10 | 12 | ||
| 11 | import * as RpcHttp from "../rpc/http.ts"; | 13 | import * as RpcHttp from "../rpc/http.ts"; |
| 12 | import * as ClientCapabilities from "../platform/capabilities.ts"; | 14 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 15 | import { fetchRemoteSessionState } from "../authorization/remote.ts"; | ||
| 13 | import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; | 16 | import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; |
| 14 | import { BearerConnectionTarget } from "./model.ts"; | 17 | import { BearerConnectionTarget } from "./model.ts"; |
| 15 | import { | 18 | import { |
| ... | @@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed( | ... | @@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed( |
| 26 | deviceType: "desktop", | 29 | deviceType: "desktop", |
| 27 | os: "Test OS", | 30 | os: "Test OS", |
| 28 | }, | 31 | }, |
| 29 | scopes: AuthStandardClientScopes, | ||
| 30 | }), | 32 | }), |
| 31 | ); | 33 | ); |
| 32 | 34 | ||
| ... | @@ -36,8 +38,11 @@ function layerPairingHttp( | ... | @@ -36,8 +38,11 @@ function layerPairingHttp( |
| 36 | readonly failDescriptor?: boolean; | 38 | readonly failDescriptor?: boolean; |
| 37 | readonly protocolVersion?: number; | 39 | readonly protocolVersion?: number; |
| 38 | readonly selfUpdate?: boolean; | 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 | const fetchFn = ((input, init = {}) => { | 46 | const fetchFn = ((input, init = {}) => { |
| 42 | const url = String(input); | 47 | const url = String(input); |
| 43 | calls.push({ url, init }); | 48 | calls.push({ url, init }); |
| ... | @@ -67,13 +72,46 @@ function layerPairingHttp( | ... | @@ -67,13 +72,46 @@ function layerPairingHttp( |
| 67 | } | 72 | } |
| 68 | 73 | ||
| 69 | if (url.endsWith("/oauth/token")) { | 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 | return Promise.resolve( | 92 | return Promise.resolve( |
| 71 | Response.json({ | 93 | Response.json({ |
| 72 | access_token: "bearer-token", | 94 | access_token: "bearer-token", |
| 73 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | 95 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 74 | token_type: "Bearer", | 96 | token_type: "Bearer", |
| 75 | expires_in: 3600, | 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,8 +161,10 @@ describe("connection onboarding", () => { |
| 123 | : String(tokenRequest?.init.body); | 161 | : String(tokenRequest?.init.body); |
| 124 | const tokenParams = new URLSearchParams(tokenBody); | 162 | const tokenParams = new URLSearchParams(tokenBody); |
| 125 | expect(tokenParams.get("subject_token")).toBe("pairing-token"); | 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 | expect(tokenParams.get("client_label")).toBe("T3 Code Test"); | 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,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 | it.effect("refuses to add a route that reaches a different machine, keeping the code", () => | 214 | it.effect("refuses to add a route that reaches a different machine, keeping the code", () => |
| 154 | Effect.gen(function* () { | 215 | Effect.gen(function* () { |
packages/client-runtime/src/connection/onboarding.ts-1| ... | @@ -131,7 +131,6 @@ export const preparePairingRegistration = Effect.fn( | ... | @@ -131,7 +131,6 @@ export const preparePairingRegistration = Effect.fn( |
| 131 | const access = yield* bootstrapRemoteBearerSession({ | 131 | const access = yield* bootstrapRemoteBearerSession({ |
| 132 | httpBaseUrl: target.httpBaseUrl, | 132 | httpBaseUrl: target.httpBaseUrl, |
| 133 | credential: target.credential, | 133 | credential: target.credential, |
| 134 | scopes: presentation.scopes, | ||
| 135 | clientMetadata: presentation.metadata, | 134 | clientMetadata: presentation.metadata, |
| 136 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); | 135 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); |
| 137 | const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl); | 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,10 +382,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 382 | const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => | 382 | const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => |
| 383 | Effect.gen(function* () { | 383 | Effect.gen(function* () { |
| 384 | const target = route.target; | 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 | ...PREPARED, | 390 | ...PREPARED, |
| 387 | environmentId: target.environmentId, | 391 | environmentId: target.environmentId, |
| 388 | label: target.label, | 392 | label: target.label, |
| 393 | httpAuthorization: Option.isSome(credential) | ||
| 394 | ? { _tag: "Bearer", token: credential.value.token } | ||
| 395 | : null, | ||
| 389 | target, | 396 | target, |
| 390 | }; | 397 | }; |
| 391 | if (options?.prepareError) return yield* options.prepareError; | 398 | if (options?.prepareError) return yield* options.prepareError; |
| ... | @@ -1201,6 +1208,80 @@ describe("EnvironmentRegistry", () => { | ... | @@ -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 | it.effect("ignores retry signals for environments that are no longer registered", () => | 1285 | it.effect("ignores retry signals for environments that are no longer registered", () => |
| 1205 | Effect.gen(function* () { | 1286 | Effect.gen(function* () { |
| 1206 | const harness = yield* makeHarness([]); | 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,7 +452,10 @@ export const make = Effect.gen(function* () { |
| 452 | SubscriptionRef.changes(entries), | 452 | SubscriptionRef.changes(entries), |
| 453 | ).pipe( | 453 | ).pipe( |
| 454 | Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), | 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 | Stream.switchMap( | 459 | Stream.switchMap( |
| 457 | Option.match({ | 460 | Option.match({ |
| 458 | onNone: () => Stream.empty, | 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,7 +176,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 176 | ClientCapabilities.ClientPresentation, | 176 | ClientCapabilities.ClientPresentation, |
| 177 | ClientCapabilities.ClientPresentation.of({ | 177 | ClientCapabilities.ClientPresentation.of({ |
| 178 | metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, | 178 | metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, |
| 179 | scopes: [], | ||
| 180 | }), | 179 | }), |
| 181 | ), | 180 | ), |
| 182 | Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), | 181 | Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), |
packages/client-runtime/src/connection/supervisor.test.ts-1| ... | @@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => { | ... | @@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => { |
| 1572 | }), | 1572 | }), |
| 1573 | Layer.succeed(ClientCapabilities.ClientPresentation, { | 1573 | Layer.succeed(ClientCapabilities.ClientPresentation, { |
| 1574 | metadata: { label: "Test client", deviceType: "desktop" }, | 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,6 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type AuthClientPresentationMetadata, | 2 | type AuthClientPresentationMetadata, |
| 3 | type AuthEnvironmentScope, | ||
| 4 | type DesktopSshEnvironmentBootstrap, | 3 | type DesktopSshEnvironmentBootstrap, |
| 5 | type DesktopSshEnvironmentTarget, | 4 | type DesktopSshEnvironmentTarget, |
| 6 | EnvironmentId, | 5 | EnvironmentId, |
| ... | @@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service< | ... | @@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service< |
| 45 | ClientPresentation, | 44 | ClientPresentation, |
| 46 | { | 45 | { |
| 47 | readonly metadata: AuthClientPresentationMetadata; | 46 | readonly metadata: AuthClientPresentationMetadata; |
| 48 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | ||
| 49 | } | 47 | } |
| 50 | >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} | 48 | >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} |
| 51 | 49 |