@gjermundgaraba/clankerauth-sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @gjermundgaraba/clankerauth-sdk
2
2
 
3
- Effect-native [Clanker Auth](https://github.com/gjermundgaraba/clankerauth) verification and browser sessions. Verifies JWT access tokens and API keys and runs browser login with encrypted server-held credentials. Optional **effect-actions** integration provides request-scoped identity and OAuth discovery.
3
+ Effect-native [Clanker Auth](https://github.com/gjermundgaraba/clankerauth) verification. Verifies JWT access tokens and API keys, including the tokens a reverse proxy obtains through the issuer's forward auth for browser sessions. Optional **effect-actions** integration provides request-scoped identity and OAuth discovery.
4
4
 
5
5
  ## Install
6
6
 
@@ -37,7 +37,7 @@ const makeVerifier = Verifier.make({
37
37
  Install the optional peer dependency and import the integration subpath:
38
38
 
39
39
  ```sh
40
- vp add @gjermundgaraba/effect-actions@0.1.0
40
+ vp add @gjermundgaraba/effect-actions@0.2.0
41
41
  ```
42
42
 
43
43
  Core and integration declarations both type-check without `skipLibCheck`.
@@ -107,72 +107,16 @@ JWTs are checked against issuer, audience, EdDSA signature, token type, required
107
107
 
108
108
  The bundled issuer does not configure automatic signing-key rotation. Immediate-use rotation is supported, but tokens signed by a new key can be rejected during the thirty-second cooldown after a successful lookup. Publish-before-use avoids that short window; it is not mandatory.
109
109
 
110
- ## Browser sessions
110
+ ## Browser applications
111
111
 
112
- Register a confidential client with the exact `callbackUrl` supplied to the session. It must be an absolute HTTP(S) URL without credentials or a fragment; query parameters are allowed. The session derives its browser origin and secure-cookie policy from this URL.
113
-
114
- Provide a `SessionStore` layer implementing Effect operations:
115
-
116
- - `get(id)` → `{ payload, expires } | undefined`
117
- - `put(id, payload, expires)`
118
- - `delete(id)`
119
- - `sweep(now)`: remove rows expiring at or before `now`
120
-
121
- Map expected database failures to `StoreError({ operation, cause })` at your persistence adapter. Do not put SQL, secrets, credentials or provider payloads in public errors. The store owns persistence, not encryption or session policy. **Run one process per store and acquire one shared browser-session capability per cookie/store configuration.**
122
-
123
- ```ts
124
- import { Effect, Layer, Redacted } from "effect";
125
- import { BrowserSession } from "@gjermundgaraba/clankerauth-sdk";
126
- import { BrowserActions, Resource } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
127
-
128
- // Inside application construction, with HttpClient and SessionStore provided:
129
- const browser =
130
- yield *
131
- BrowserSession.make({
132
- issuer: "https://auth.internal/api/auth",
133
- resource: "https://notes.internal/api",
134
- callbackUrl: "https://notes.internal/workspace/auth/callback",
135
- clientId,
136
- clientSecret: Redacted.make(clientSecret),
137
- secret: Redacted.make(sessionSecret), // at least 32 characters; retain across restarts
138
- scopes: ["notes:read", "notes:write"],
139
- cookie: { name: "notes" },
140
- verifyToken: api.verifier.verifyToken,
141
- });
142
-
143
- const browserRoutes = Layer.mergeAll(
144
- BrowserActions.layer(browser),
145
- Http.layer(app).pipe(Layer.provide(Resource.middleware(api, { browser }).layer)),
146
- );
147
- ```
148
-
149
- This example's construction fragment belongs inside `Effect.gen`. Supply configuration and secrets at your composition root; the SDK does not read environment variables or start its own runtime.
150
-
151
- `BrowserActions.layer` registers:
152
-
153
- - `POST /auth/browser/login`: accepts `{ returnTo: "/path" }`, returns `{ url }`.
154
- - `GET <callback pathname>`: consumes the one-time transaction and redirects (`/workspace/auth/callback` above).
155
- - `POST /auth/browser/session`: returns the subject, scopes and issuer, or `Unauthorized`.
156
- - `POST /auth/browser/logout`: ends the local session and attempts provider revocation.
157
-
158
- Login, session and logout are effect-actions, with JSON bodies (`{}` for session and logout). Browser clients import the pure `Http` contract from `@gjermundgaraba/clankerauth-sdk/browser-api`. Only the OAuth callback is an ordinary HTTP endpoint. The callback uses the exact configured URL; action routes live under `/auth/browser`. Callback failures redirect to the origin root with an `auth_error` query parameter; a root-mounted callback returns the sanitized HTTP error to avoid a redirect loop. Login destinations must be same-origin paths other than the callback pathname. The host sets request-body limits, for example `HttpIncomingMessage.MaxBodySize` on Effect's Node server (Clanker Auth's own adapter uses 64 KiB). Responses are non-cacheable and use `Referrer-Policy: no-referrer`.
159
-
160
- Tokens always remain in the server-side session store, never in browser JavaScript.
161
-
162
- Pass `browser` only for HTTP routes the application's own pages call. A request with an Authorization header is always verified as a bearer token; without one, the session cookie authenticates it and Origin is checked on unsafe methods. **Never pass `browser` for MCP.**
163
-
164
- The session capability does not accept incoming HTTP requests: `login(returnTo)`, `callback(url, transactionCookie)`, `session(sessionCookie)`, `accessToken(sessionCookie)`, and `logout(sessionCookie)` are Effects. HTTP handlers own cookie extraction and presentation.
165
-
166
- Cookies are HttpOnly, SameSite=Lax and Secure on HTTPS. Login uses S256 PKCE, state, nonce, exact issuer validation and signed ID-token checks. Stored payloads remain AES-256-GCM sealed under the configured secret with hashed cookie identifiers.
167
-
168
- Refreshes and logout serialize per session. A durable no-replay marker is written before refresh; interruptions, ambiguous outcomes and restarts cannot reuse that refresh token. Discovery failure before the marker remains retryable. Operational refresh failures return 503 and invalidate the session; subsequent access requires login. Local logout remains authoritative if provider revocation fails, with a sanitized Effect warning.
112
+ Browser apps do not use this package. Put them behind a reverse proxy with the issuer's forward auth, described in the Clanker Auth README; the proxy adds an `Authorization` header the same verifier above accepts.
169
113
 
170
114
  ## Errors and observability
171
115
 
172
- Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `RateLimited` (429), `ProviderUnavailable` and `StoreError` (503). Browser input errors use `InvalidRequest` (400). Invalid construction fails with `ConfigurationError`.
116
+ Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `RateLimited` (429) and `ProviderUnavailable` (503). Invalid construction fails with `ConfigurationError`.
173
117
 
174
- The SDK Resource adapter owns authentication error encoding and challenge headers; effect-actions supplies request-scoped identity and the no-store response policy. Browser actions use their declared error schemas; the callback handles redirects. Defects and interruption are not relabeled as authentication rejection. Named effects supply tracing boundaries; application logging/tracing layers remain caller-owned. No `onFailure` callbacks or hidden runtime.
118
+ The SDK Resource adapter owns authentication error encoding and challenge headers; effect-actions supplies request-scoped identity and the no-store response policy. Defects and interruption are not relabeled as authentication rejection. Named effects supply tracing boundaries; application logging/tracing layers remain caller-owned. No `onFailure` callbacks or hidden runtime.
175
119
 
176
- Operational failures retain their underlying `cause` for application-side Effect error handling. `ProviderUnavailable`, `StoreError`, and `Unauthorized` keep this diagnostic field outside their public schemas; BrowserActions and effect-actions serialize only those schemas. Causes may contain sensitive transport or provider details: inspect selectively, never serialize them into responses or log them indiscriminately.
120
+ Operational failures retain their underlying `cause` for application-side Effect error handling. `ProviderUnavailable` and `Unauthorized` keep this diagnostic field outside their public schemas; effect-actions serializes only those schemas. Causes may contain sensitive transport or provider details: inspect selectively, never serialize them into responses or log them indiscriminately.
177
121
 
178
122
  MIT licensed.
@@ -1,3 +1,2 @@
1
1
  export * as Resource from "./resource.ts";
2
2
  export { CurrentPrincipal } from "./resource.ts";
3
- export * as BrowserActions from "./browser-actions.ts";
@@ -1,3 +1,2 @@
1
1
  export * as Resource from "./resource.js";
2
2
  export { CurrentPrincipal } from "./resource.js";
3
- export * as BrowserActions from "./browser-actions.js";
package/dist/errors.d.ts CHANGED
@@ -31,28 +31,13 @@ export declare class ProviderUnavailable extends ProviderUnavailable_base {
31
31
  readonly cause?: unknown;
32
32
  });
33
33
  }
34
- declare const StoreError_base: Schema.Class<StoreError, Schema.TaggedStruct<"StoreError", {
35
- readonly operation: Schema.String;
36
- }>, import("effect/Cause").YieldableError>;
37
- export declare class StoreError extends StoreError_base {
38
- readonly cause?: unknown;
39
- constructor(options: {
40
- readonly operation: string;
41
- readonly cause?: unknown;
42
- });
43
- }
44
- declare const InvalidRequest_base: Schema.Class<InvalidRequest, Schema.TaggedStruct<"InvalidRequest", {
45
- readonly message: Schema.String;
46
- }>, import("effect/Cause").YieldableError>;
47
- export declare class InvalidRequest extends InvalidRequest_base {
48
- }
49
34
  declare const ConfigurationError_base: Schema.Class<ConfigurationError, Schema.TaggedStruct<"ConfigurationError", {
50
35
  readonly message: Schema.String;
51
36
  }>, import("effect/Cause").YieldableError>;
52
37
  export declare class ConfigurationError extends ConfigurationError_base {
53
38
  }
54
- export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable, typeof StoreError];
55
- export type AuthenticationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable | StoreError;
39
+ export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable];
40
+ export type AuthenticationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
56
41
  /** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
57
42
  export declare const encodeError: <const Schemas extends ReadonlyArray<Schema.Top>>(schemas: Schemas) => (error: Schemas[number]["Type"], headers?: Headers.Input) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, { [K in keyof Schemas]: Schemas[K]["EncodingServices"]; }[number]>;
58
43
  export {};
package/dist/errors.js CHANGED
@@ -17,14 +17,6 @@ export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailab
17
17
  Object.defineProperty(this, "cause", { value: options.cause });
18
18
  }
19
19
  }
20
- export class StoreError extends Schema.TaggedError()("StoreError", { operation: Schema.String }, { httpApiStatus: 503 }) {
21
- constructor(options) {
22
- super({ operation: options.operation });
23
- Object.defineProperty(this, "cause", { value: options.cause });
24
- }
25
- }
26
- export class InvalidRequest extends Schema.TaggedError()("InvalidRequest", { message: Schema.String }, { httpApiStatus: 400 }) {
27
- }
28
20
  export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
29
21
  }
30
22
  export const authenticationErrors = [
@@ -32,7 +24,6 @@ export const authenticationErrors = [
32
24
  Forbidden,
33
25
  RateLimited,
34
26
  ProviderUnavailable,
35
- StoreError,
36
27
  ];
37
28
  /** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
38
29
  export const encodeError = (schemas) => {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,3 @@
1
1
  export * as Verifier from "./verify.ts";
2
- export * as BrowserSession from "./browser.ts";
3
- export { SessionStore, type Row } from "./store.ts";
4
2
  export type { Principal } from "./verify.ts";
5
- export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, StoreError, InvalidRequest, ConfigurationError, type AuthenticationError, } from "./errors.ts";
3
+ export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, ConfigurationError, type AuthenticationError, } from "./errors.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,2 @@
1
1
  export * as Verifier from "./verify.js";
2
- export * as BrowserSession from "./browser.js";
3
- export { SessionStore } from "./store.js";
4
- export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, StoreError, InvalidRequest, ConfigurationError, } from "./errors.js";
2
+ export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, ConfigurationError, } from "./errors.js";
@@ -3,7 +3,6 @@ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
3
  import { ConfigurationError, Forbidden, RateLimited, Unauthorized } from "./errors.ts";
4
4
  import type { AuthenticationError } from "./errors.ts";
5
5
  import * as Verifier from "./verify.ts";
6
- import type { BrowserSession } from "./browser.ts";
7
6
  declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
8
7
  export declare class CurrentPrincipal extends CurrentPrincipal_base {
9
8
  }
@@ -63,16 +62,8 @@ export declare const make: (options: Options) => Effect.Effect<{
63
62
  issuer: string;
64
63
  }, ConfigurationError, import("effect/unstable/http/HttpClient").HttpClient>;
65
64
  export type Resource = Effect.Success<ReturnType<typeof make>>;
66
- export interface MiddlewareOptions {
67
- /** Authenticate requests without an Authorization header by session cookie. Never for MCP. */
68
- readonly browser?: BrowserSession;
69
- }
70
- /**
71
- * Bearer authentication providing `CurrentPrincipal`. With `browser`, a request
72
- * without an Authorization header is authenticated by the session cookie instead,
73
- * with Origin checked on unsafe methods.
74
- */
75
- export declare const middleware: (resource: Resource, options?: MiddlewareOptions) => import("effect/unstable/http/HttpRouter").Middleware<{
65
+ /** Bearer authentication providing `CurrentPrincipal`. */
66
+ export declare const middleware: (resource: Resource) => import("effect/unstable/http/HttpRouter").Middleware<{
76
67
  provides: CurrentPrincipal;
77
68
  handles: never;
78
69
  error: never;
package/dist/resource.js CHANGED
@@ -40,20 +40,10 @@ export const make = Effect.fn("Resource.make")(function* (options) {
40
40
  issuer: options.issuer,
41
41
  };
42
42
  });
43
- /**
44
- * Bearer authentication providing `CurrentPrincipal`. With `browser`, a request
45
- * without an Authorization header is authenticated by the session cookie instead,
46
- * with Origin checked on unsafe methods.
47
- */
48
- export const middleware = (resource, options = {}) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
43
+ /** Bearer authentication providing `CurrentPrincipal`. */
44
+ export const middleware = (resource) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
49
45
  const request = yield* HttpServerRequest.HttpServerRequest;
50
- const { browser } = options;
51
- if (browser === undefined || request.headers.authorization !== undefined)
52
- return yield* resource.verifier.verify(request.headers.authorization);
53
- const session = request.cookies[`${browser.cookie.name}_session`];
54
- if (session !== undefined && !["GET", "HEAD", "OPTIONS"].includes(request.method))
55
- yield* browser.checkOrigin(request.headers.origin);
56
- return yield* resource.verifier.verifyToken(yield* browser.accessToken(session));
46
+ return yield* resource.verifier.verify(request.headers.authorization);
57
47
  }).pipe(Effect.catch((error) => Effect.gen(function* () {
58
48
  const request = yield* HttpServerRequest.HttpServerRequest;
59
49
  // RFC 6750 §3.1: a request that carried no credentials gets no error code.
@@ -1,16 +1,4 @@
1
- import { Cause, Effect } from "effect";
1
+ import { Effect } from "effect";
2
2
  import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
- import type { HttpClientError } from "effect/unstable/http/HttpClientError";
4
3
  import { ProviderUnavailable } from "./errors.ts";
5
- /** Preserve Effect causes while crossing the foreign library's Promise callback boundary. */
6
- export declare class TransportFailure extends Error {
7
- readonly effectCause: Cause.Cause<ProviderUnavailable | HttpClientError>;
8
- constructor(effectCause: Cause.Cause<ProviderUnavailable | HttpClientError>);
9
- }
10
- /**
11
- * The only Effect -> Promise bridge: the third-party OAuth library requires Fetch.
12
- * Capture the calling fiber's context per invocation, and propagate its AbortSignal.
13
- * Buffer small protocol responses so no response resource outlives the adapter effect.
14
- */
15
- export declare const protocolTransport: () => Effect.Effect<(url: string, init: RequestInit) => Promise<Response>, never, HttpClient.HttpClient>;
16
4
  export declare const execute: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) => Effect.Effect<import("effect/unstable/http/HttpClientResponse").HttpClientResponse, ProviderUnavailable, import("effect/Scope").Scope>;
package/dist/transport.js CHANGED
@@ -1,46 +1,6 @@
1
- import { Cause, Context, Effect, Exit } from "effect";
1
+ import { Context, Effect } from "effect";
2
2
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
3
  import { ProviderUnavailable } from "./errors.js";
4
- /** Preserve Effect causes while crossing the foreign library's Promise callback boundary. */
5
- export class TransportFailure extends Error {
6
- effectCause;
7
- constructor(effectCause) {
8
- super("Protocol transport failed");
9
- this.effectCause = effectCause;
10
- }
11
- }
12
- /**
13
- * The only Effect -> Promise bridge: the third-party OAuth library requires Fetch.
14
- * Capture the calling fiber's context per invocation, and propagate its AbortSignal.
15
- * Buffer small protocol responses so no response resource outlives the adapter effect.
16
- */
17
- export const protocolTransport = Effect.fn("Auth.protocolTransport")(function* () {
18
- const client = yield* HttpClient.HttpClient;
19
- const context = yield* Effect.context();
20
- return (url, init) => Effect.runPromiseExitWith(context)(Effect.gen(function* () {
21
- const request = yield* Effect.try({
22
- try: () => HttpClientRequest.fromWeb(new Request(url, init)),
23
- catch: (cause) => new ProviderUnavailable({ operation: "request", cause }),
24
- });
25
- const response = yield* execute(client, request);
26
- if (response.status >= 300 && response.status < 400)
27
- return yield* new ProviderUnavailable({
28
- operation: "redirect",
29
- cause: { status: response.status },
30
- });
31
- const body = yield* response.arrayBuffer;
32
- return new Response(response.status === 204 || response.status === 205 || response.status === 304
33
- ? null
34
- : body, {
35
- status: response.status,
36
- headers: response.headers,
37
- });
38
- }).pipe(Effect.scoped), { signal: init.signal ?? undefined }).then((exit) => {
39
- if (Exit.isFailure(exit))
40
- throw new TransportFailure(exit.cause);
41
- return exit.value;
42
- });
43
- });
44
4
  // Node transports do not follow redirects by default. Also pin FetchHttpClient's policy;
45
5
  // forwarding credentials through a redirect is not an authentication fallback.
46
6
  export const execute = (client, request) => Effect.contextWith((context) => HttpClient.withScope(client)
package/dist/verify.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Clock, Effect, Exit, Schema, Semaphore } from "effect";
2
- import { createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
2
+ import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
4
  import { ConfigurationError, Forbidden, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
5
5
  import { execute } from "./transport.js";
@@ -26,16 +26,6 @@ const KeyResponse = Schema.Struct({
26
26
  scopes: Schema.Array(Schema.NonEmptyString),
27
27
  expiresAt: Schema.NullOr(Schema.String),
28
28
  });
29
- const credentialErrors = [
30
- errors.JWTExpired,
31
- errors.JWTClaimValidationFailed,
32
- errors.JWSSignatureVerificationFailed,
33
- errors.JWSInvalid,
34
- errors.JWTInvalid,
35
- errors.JOSENotSupported,
36
- errors.JOSEAlgNotAllowed,
37
- errors.JWKSNoMatchingKey,
38
- ];
39
29
  const unauthorized = () => new Unauthorized({ message: "Authentication required" });
40
30
  /** Acquire once per resource; transport is selected by the application, not the SDK. */
41
31
  export const make = Effect.fn("Verifier.make")(function* (options) {
@@ -67,9 +57,8 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
67
57
  let keys = yield* makeCachedKeys;
68
58
  const resolveKey = (resolve, header) => Effect.tryPromise({
69
59
  try: () => resolve(header),
70
- catch: (cause) => cause instanceof errors.JWKSNoMatchingKey
71
- ? unauthorized()
72
- : new ProviderUnavailable({ operation: "jwks.resolve", cause }),
60
+ // Selecting from fetched keys does no I/O: every failure is the credential's.
61
+ catch: unauthorized,
73
62
  });
74
63
  const signingKey = Effect.fn("Verifier.signingKey")(function* (header) {
75
64
  const resolve = yield* keys;
@@ -97,8 +86,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
97
86
  try: () => decodeProtectedHeader(token),
98
87
  catch: unauthorized,
99
88
  });
100
- // Reject unsupported algorithms before doing provider I/O; JOSE still enforces its allowlist.
101
- if (header.alg !== "EdDSA")
89
+ // The issuer always names its key, so key selection is never ambiguous. Reject other
90
+ // tokens before doing provider I/O; JOSE still enforces its algorithm allowlist.
91
+ if (header.alg !== "EdDSA" || header.kid === undefined)
102
92
  return yield* unauthorized();
103
93
  const key = yield* signingKey(header);
104
94
  const now = yield* Clock.currentTimeMillis;
@@ -111,9 +101,8 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
111
101
  requiredClaims: ["sub", "client_id", "scope", "iat", "exp"],
112
102
  currentDate: new Date(now),
113
103
  }),
114
- catch: (cause) => credentialErrors.some((kind) => cause instanceof kind)
115
- ? unauthorized()
116
- : new ProviderUnavailable({ operation: "jwt.verify", cause }),
104
+ // Verification against a resolved key does no I/O either.
105
+ catch: unauthorized,
117
106
  });
118
107
  if (payload.cnf !== undefined)
119
108
  return yield* unauthorized();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.4.0",
4
- "description": "Effect-native Clanker Auth verification and browser sessions, with optional effect-actions integration",
3
+ "version": "0.5.0",
4
+ "description": "Effect-native Clanker Auth access-token and API-key verification, with optional effect-actions integration",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -20,10 +20,6 @@
20
20
  "./effect-actions": {
21
21
  "types": "./dist/effect-actions.d.ts",
22
22
  "import": "./dist/effect-actions.js"
23
- },
24
- "./browser-api": {
25
- "types": "./dist/browser-api.d.ts",
26
- "import": "./dist/browser-api.js"
27
23
  }
28
24
  },
29
25
  "publishConfig": {
@@ -31,18 +27,17 @@
31
27
  },
32
28
  "dependencies": {
33
29
  "effect": "4.0.0-rc.116",
34
- "jose": "6.2.12",
35
- "oauth4webapi": "3.8.8"
30
+ "jose": "6.2.12"
36
31
  },
37
32
  "devDependencies": {
38
- "@gjermundgaraba/effect-actions": "0.1.0",
33
+ "@gjermundgaraba/effect-actions": "0.2.0",
39
34
  "@types/node": "26.6.2",
40
35
  "typescript": "7.0.2",
41
36
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
42
37
  "vite-plus": "0.3.3"
43
38
  },
44
39
  "peerDependencies": {
45
- "@gjermundgaraba/effect-actions": "0.1.0"
40
+ "@gjermundgaraba/effect-actions": "0.2.0"
46
41
  },
47
42
  "peerDependenciesMeta": {
48
43
  "@gjermundgaraba/effect-actions": {
@@ -1,5 +0,0 @@
1
- import { Layer } from "effect";
2
- import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
3
- import { type BrowserSession } from "./browser.ts";
4
- /** Login, session and logout actions under `/auth/browser`, plus the OAuth callback route. */
5
- export declare const layer: (browser: BrowserSession) => Layer.Layer<never, never, import("effect/FileSystem").FileSystem | import("effect/unstable/http/Etag").Generator | import("effect/unstable/http/HttpPlatform").HttpPlatform | HttpRouter.HttpRouter | import("effect/Path").Path | HttpRouter.Request<"Requires", HttpServerRequest.HttpServerRequest>>;
@@ -1,54 +0,0 @@
1
- import { Actions, Http } from "./browser-api.js";
2
- import { Duration, Effect, Layer } from "effect";
3
- import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse, } from "effect/unstable/http";
4
- import { transactionLifetime } from "./browser.js";
5
- import { authenticationErrors, encodeError, InvalidRequest } from "./errors.js";
6
- const encode = encodeError([...authenticationErrors, InvalidRequest]);
7
- /** Login, session and logout actions under `/auth/browser`, plus the OAuth callback route. */
8
- export const layer = (browser) => {
9
- const cookie = (suffix, value, age) => HttpServerResponse.setCookieUnsafe(`${browser.cookie.name}_${suffix}`, value, {
10
- path: "/",
11
- httpOnly: true,
12
- sameSite: "lax",
13
- secure: browser.cookie.secure,
14
- maxAge: Duration.seconds(age),
15
- });
16
- const setCookie = (suffix, value, age) => HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(cookie(suffix, value, age)(response)));
17
- const sameOrigin = Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => browser.checkOrigin(request.headers.origin));
18
- const sessionCookie = Effect.map(HttpServerRequest.HttpServerRequest, (request) => request.cookies[`${browser.cookie.name}_session`]);
19
- const actions = Actions.implement({
20
- login: ({ returnTo }) => Effect.gen(function* () {
21
- yield* sameOrigin;
22
- const result = yield* browser.login(returnTo);
23
- yield* setCookie("login", result.transaction, transactionLifetime);
24
- return { url: result.url };
25
- }),
26
- session: () => Effect.gen(function* () {
27
- yield* sameOrigin;
28
- return yield* browser.session(yield* sessionCookie);
29
- }),
30
- logout: () => Effect.gen(function* () {
31
- yield* sameOrigin;
32
- yield* browser.logout(yield* sessionCookie);
33
- yield* setCookie("session", "", 0);
34
- return {};
35
- }),
36
- });
37
- const callback = Effect.gen(function* () {
38
- const request = yield* HttpServerRequest.HttpServerRequest;
39
- const url = yield* Effect.try({
40
- try: () => new URL(request.originalUrl, browser.origin),
41
- catch: () => new InvalidRequest({ message: "Invalid callback URL" }),
42
- });
43
- const result = yield* browser.callback(url, request.cookies[`${browser.cookie.name}_login`]);
44
- return HttpServerResponse.redirect(result.location, { status: 302 }).pipe(cookie("session", result.session, browser.cookie.lifetime), cookie("login", "", 0));
45
- }).pipe(Effect.catch((error) => encode(error).pipe(Effect.map((response) =>
46
- // A root-mounted callback must report its error directly to avoid a redirect loop.
47
- browser.callbackPath === "/"
48
- ? response
49
- : HttpServerResponse.redirect(`${browser.origin}/?auth_error=${response.status === 503 ? "unavailable" : "login_failed"}`, { status: 302 })), Effect.map(cookie("login", "", 0)))));
50
- return Layer.mergeAll(Http.layer(actions), HttpRouter.add("GET", browser.callbackPath, callback)).pipe(Layer.provide(HttpRouter.middleware((effect) => HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeaders(response, {
51
- "cache-control": "no-store",
52
- "referrer-policy": "no-referrer",
53
- }))).pipe(Effect.andThen(effect))).layer));
54
- };
@@ -1,23 +0,0 @@
1
- import { Schema } from "effect";
2
- import * as Action from "@gjermundgaraba/effect-actions/Action";
3
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
4
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
- import { InvalidRequest } from "./errors.ts";
6
- export declare const Actions: ActionGroup.Group<"browser", readonly [Action.Action<"login", Schema.Struct<{
7
- readonly returnTo: Schema.String;
8
- }>, Schema.Struct<{
9
- readonly url: Schema.String;
10
- }>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>, Action.Action<"session", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
11
- readonly subject: Schema.String;
12
- readonly scopes: Schema.$Array<Schema.String>;
13
- readonly issuer: Schema.String;
14
- }>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>, Action.Action<"logout", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{}>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>], []>;
15
- export declare const Http: ActionHttp.Http<readonly [ActionGroup.Group<"browser", readonly [Action.Action<"login", Schema.Struct<{
16
- readonly returnTo: Schema.String;
17
- }>, Schema.Struct<{
18
- readonly url: Schema.String;
19
- }>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>, Action.Action<"session", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
20
- readonly subject: Schema.String;
21
- readonly scopes: Schema.$Array<Schema.String>;
22
- readonly issuer: Schema.String;
23
- }>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>, Action.Action<"logout", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{}>, readonly [typeof import("./errors.ts").Unauthorized, typeof import("./errors.ts").Forbidden, typeof import("./errors.ts").RateLimited, typeof import("./errors.ts").ProviderUnavailable, typeof import("./errors.ts").StoreError, typeof InvalidRequest], true, false>], []>]>;
@@ -1,24 +0,0 @@
1
- import { Schema } from "effect";
2
- import * as Action from "@gjermundgaraba/effect-actions/Action";
3
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
4
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
- import { authenticationErrors, InvalidRequest } from "./errors.js";
6
- export const Actions = ActionGroup.make({ name: "browser", errors: [...authenticationErrors, InvalidRequest] }, Action.make("login", {
7
- description: "Start an OAuth authorization-code flow with PKCE.",
8
- input: Schema.Struct({ returnTo: Schema.String }),
9
- success: Schema.Struct({ url: Schema.String }),
10
- mcp: false,
11
- }), Action.make("session", {
12
- description: "Read the current application session.",
13
- success: Schema.Struct({
14
- subject: Schema.String,
15
- scopes: Schema.Array(Schema.String),
16
- issuer: Schema.String,
17
- }),
18
- mcp: false,
19
- }), Action.make("logout", {
20
- description: "End this application session and revoke its refresh token.",
21
- success: Schema.Struct({}),
22
- mcp: false,
23
- }));
24
- export const Http = ActionHttp.make({ apiPath: "/auth" }, Actions);
package/dist/browser.d.ts DELETED
@@ -1,49 +0,0 @@
1
- import { Effect, Redacted } from "effect";
2
- import { HttpClient } from "effect/unstable/http";
3
- import { ConfigurationError, Forbidden, InvalidRequest, ProviderUnavailable, Unauthorized } from "./errors.ts";
4
- import { SessionStore } from "./store.ts";
5
- import type { Verifier } from "./verify.ts";
6
- export interface Options {
7
- readonly issuer: string;
8
- readonly clientId: string;
9
- readonly clientSecret: Redacted.Redacted<string>;
10
- readonly callbackUrl: string;
11
- readonly resource: string;
12
- readonly scopes: readonly string[];
13
- readonly secret: Redacted.Redacted<string>;
14
- readonly cookie: {
15
- readonly name: string;
16
- readonly lifetime?: number;
17
- };
18
- readonly verifyToken: Verifier["verifyToken"];
19
- }
20
- export declare const transactionLifetime = 600;
21
- /** Domain/session capability. Cookies and HTTP response mapping live in BrowserActions. */
22
- export declare const make: (options: Options) => Effect.Effect<{
23
- login: (returnTo: string) => Effect.Effect<{
24
- url: string;
25
- transaction: string;
26
- }, InvalidRequest | ProviderUnavailable | import("./errors.ts").StoreError | Unauthorized, never>;
27
- callback: (url: URL, raw: string | undefined) => Effect.Effect<{
28
- session: string;
29
- location: string;
30
- }, import("./errors.ts").StoreError | import("./verify.ts").VerificationError, never>;
31
- session: (raw: string | undefined) => Effect.Effect<{
32
- subject: string;
33
- scopes: readonly string[];
34
- issuer: string;
35
- }, import("./errors.ts").StoreError | import("./verify.ts").VerificationError, never>;
36
- logout: (raw: string | undefined) => Effect.Effect<void, import("./errors.ts").StoreError | Unauthorized, never>;
37
- accessToken: (raw: string | undefined) => Effect.Effect<string, import("./errors.ts").StoreError | import("./verify.ts").VerificationError, never>;
38
- origin: string;
39
- callbackPath: `/${string}`;
40
- resource: string;
41
- issuer: string;
42
- cookie: {
43
- name: string;
44
- lifetime: number;
45
- secure: boolean;
46
- };
47
- checkOrigin: (value: string | undefined) => Effect.Effect<void, never, never> | Effect.Effect<never, Forbidden, never>;
48
- }, ConfigurationError, HttpClient.HttpClient | SessionStore>;
49
- export type BrowserSession = Effect.Success<ReturnType<typeof make>>;
package/dist/browser.js DELETED
@@ -1,303 +0,0 @@
1
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
- import { Clock, Effect, Exit, Redacted, Result, Schema } from "effect";
3
- import * as oauth from "oauth4webapi";
4
- import { Cookies, HttpClient } from "effect/unstable/http";
5
- import { ConfigurationError, Forbidden, InvalidRequest, ProviderUnavailable, Unauthorized, } from "./errors.js";
6
- import { SessionStore } from "./store.js";
7
- import { protocolTransport, TransportFailure } from "./transport.js";
8
- import * as Coordination from "./coordination.js";
9
- const Credentials = Schema.Struct({
10
- accessToken: Schema.String,
11
- refreshToken: Schema.optionalKey(Schema.String),
12
- accessExpires: Schema.Finite,
13
- subject: Schema.String,
14
- refreshBlocked: Schema.Boolean,
15
- });
16
- const Login = Schema.Struct({
17
- verifier: Schema.String,
18
- state: Schema.String,
19
- nonce: Schema.String,
20
- returnTo: Schema.String,
21
- });
22
- export const transactionLifetime = 600;
23
- const hash = (value) => createHash("sha256").update(value).digest("hex");
24
- const random = () => randomBytes(32).toString("base64url");
25
- const unauthorized = (cause) => new Unauthorized({ message: "Sign in required", cause });
26
- const pkceChallenge = Effect.fn("BrowserSession.pkceChallenge")((verifier) => Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)));
27
- /** Domain/session capability. Cookies and HTTP response mapping live in BrowserActions. */
28
- export const make = Effect.fn("BrowserSession.make")(function* (options) {
29
- const store = yield* SessionStore;
30
- const locked = yield* Coordination.make;
31
- const parsed = yield* Effect.try({
32
- try: () => {
33
- const callback = new URL(options.callbackUrl);
34
- if (!["http:", "https:"].includes(callback.protocol) ||
35
- callback.username ||
36
- callback.password ||
37
- options.callbackUrl.includes("#"))
38
- throw new Error("Callback must be an HTTP(S) URL without credentials or fragment");
39
- return { callback, issuer: new URL(options.issuer) };
40
- },
41
- catch: () => new ConfigurationError({ message: "Invalid browser callback URL or issuer" }),
42
- });
43
- if (Redacted.value(options.secret).length < 32)
44
- return yield* new ConfigurationError({ message: "Browser session secret needs 32 characters" });
45
- if (["session", "login"].some((suffix) => Result.isFailure(Cookies.makeCookie(`${options.cookie.name}_${suffix}`, ""))))
46
- return yield* new ConfigurationError({ message: "Invalid cookie name" });
47
- const { callback: callbackUrl, issuer } = parsed;
48
- const origin = callbackUrl.origin;
49
- const lifetime = options.cookie.lifetime ?? 30 * 24 * 60 * 60;
50
- if (!Number.isSafeInteger(lifetime) || lifetime <= 0)
51
- return yield* new ConfigurationError({ message: "Invalid cookie lifetime" });
52
- const redirectUri = options.callbackUrl;
53
- const key = createHash("sha256").update(Redacted.value(options.secret)).digest();
54
- const client = { client_id: options.clientId };
55
- const clientAuth = oauth.ClientSecretBasic(Redacted.value(options.clientSecret));
56
- const seal = (value) => {
57
- const iv = randomBytes(12);
58
- const cipher = createCipheriv("aes-256-gcm", key, iv);
59
- const bytes = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
60
- return Buffer.concat([iv, cipher.getAuthTag(), bytes]).toString("base64url");
61
- };
62
- const open = Effect.fn("BrowserSession.open")(function* (payload) {
63
- return yield* Effect.try({
64
- try: () => {
65
- const bytes = Buffer.from(payload, "base64url");
66
- const cipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12));
67
- cipher.setAuthTag(bytes.subarray(12, 28));
68
- return Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString("utf8");
69
- },
70
- catch: unauthorized,
71
- });
72
- });
73
- const decodeCredentials = (payload) => open(payload).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Credentials))), Effect.mapError(unauthorized));
74
- const decodeLogin = (payload) => open(payload).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Login))), Effect.mapError(unauthorized));
75
- const read = Effect.fn("BrowserSession.read")(function* (id) {
76
- const row = yield* store.get(id);
77
- if (!row)
78
- return yield* unauthorized();
79
- if (row.expires <= (yield* Clock.currentTimeMillis)) {
80
- yield* store.delete(id);
81
- return yield* unauthorized();
82
- }
83
- return row;
84
- });
85
- const write = (id, value, expires) => Effect.suspend(() => store.put(id, seal(value), expires));
86
- // Library calls are the only Promise boundary. Transport and cancellation remain Effect-owned.
87
- const transportClient = yield* HttpClient.HttpClient;
88
- const protocol = (operation, call) => Effect.gen(function* () {
89
- const fetch = yield* protocolTransport().pipe(Effect.provideService(HttpClient.HttpClient, transportClient));
90
- return yield* Effect.tryPromise({
91
- try: (signal) => call({
92
- signal,
93
- [oauth.customFetch]: fetch,
94
- [oauth.allowInsecureRequests]: issuer.protocol === "http:" &&
95
- ["localhost", "127.0.0.1", "[::1]"].includes(issuer.hostname),
96
- }),
97
- catch: (cause) => {
98
- if (cause instanceof TransportFailure)
99
- return cause;
100
- if (cause instanceof oauth.AuthorizationResponseError ||
101
- (cause instanceof oauth.OperationProcessingError &&
102
- (cause.code === oauth.JWT_TIMESTAMP_CHECK ||
103
- cause.code === oauth.JWT_CLAIM_COMPARISON)) ||
104
- (cause instanceof oauth.ResponseBodyError && cause.error === "invalid_grant"))
105
- return unauthorized(cause);
106
- return new ProviderUnavailable({ operation, cause });
107
- },
108
- }).pipe(Effect.catch((error) => error instanceof TransportFailure
109
- ? Effect.failCause(error.effectCause).pipe(Effect.mapError((cause) => cause instanceof ProviderUnavailable
110
- ? cause
111
- : new ProviderUnavailable({ operation, cause })))
112
- : Effect.fail(error)));
113
- }).pipe(Effect.timeoutOrElse({
114
- duration: "5 seconds",
115
- orElse: () => Effect.fail(new ProviderUnavailable({ operation })),
116
- }));
117
- const provider = yield* Effect.cachedWithTTL(protocol("browser.discovery", async (opts) => {
118
- const metadata = await oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, opts));
119
- if (!metadata.authorization_endpoint)
120
- throw new Error("Missing authorization endpoint");
121
- const endpoint = new URL(metadata.authorization_endpoint);
122
- if (endpoint.protocol !== "https:" &&
123
- !(endpoint.protocol === "http:" &&
124
- ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname)))
125
- throw new Error("Authorization endpoint must use HTTPS or loopback HTTP");
126
- return { ...metadata, authorization_endpoint: endpoint.href };
127
- }), (exit) => (Exit.isSuccess(exit) ? Infinity : 0));
128
- const credentials = Effect.fn("BrowserSession.credentials")(function* (result, old) {
129
- if (result.token_type !== "bearer" || !result.expires_in)
130
- return yield* unauthorized();
131
- const verified = yield* options.verifyToken(result.access_token);
132
- if (old && old.subject !== verified.subject)
133
- return yield* unauthorized();
134
- const refreshToken = result.refresh_token ?? old?.refreshToken;
135
- const next = {
136
- accessToken: result.access_token,
137
- accessExpires: (yield* Clock.currentTimeMillis) + result.expires_in * 1000,
138
- subject: verified.subject,
139
- refreshBlocked: false,
140
- };
141
- return refreshToken ? { ...next, refreshToken } : next;
142
- });
143
- const accessToken = Effect.fn("BrowserSession.accessToken")(function* (raw) {
144
- if (!raw)
145
- return yield* unauthorized();
146
- const id = `session:${hash(raw)}`;
147
- return yield* locked(id, Effect.gen(function* () {
148
- const row = yield* read(id);
149
- const saved = yield* decodeCredentials(row.payload);
150
- // A persisted in-flight refresh always requires reauthentication, even if its access token is fresh.
151
- if (saved.refreshBlocked) {
152
- yield* store.delete(id);
153
- return yield* unauthorized();
154
- }
155
- if (saved.accessExpires > (yield* Clock.currentTimeMillis) + 30_000)
156
- return saved.accessToken;
157
- if (!saved.refreshToken) {
158
- yield* store.delete(id);
159
- return yield* unauthorized();
160
- }
161
- const refreshToken = saved.refreshToken;
162
- const as = yield* provider;
163
- // Commit before the network call. Interruption/restart leaves a durable no-replay marker.
164
- yield* write(id, { ...saved, refreshBlocked: true }, row.expires).pipe(Effect.uninterruptible);
165
- const refresh = Effect.gen(function* () {
166
- const result = yield* protocol("browser.refresh", async (opts) => {
167
- const response = await oauth.refreshTokenGrantRequest(as, client, clientAuth, refreshToken, {
168
- ...opts,
169
- additionalParameters: { resource: options.resource },
170
- });
171
- const result = await oauth.processRefreshTokenResponse(as, client, response);
172
- if (result.id_token)
173
- await oauth.validateApplicationLevelSignature(as, response, opts);
174
- return result;
175
- });
176
- const next = yield* credentials(result, saved);
177
- const identity = oauth.getValidatedIdTokenClaims(result);
178
- if (identity && identity.sub !== next.subject)
179
- return yield* unauthorized();
180
- yield* write(id, next, row.expires).pipe(Effect.uninterruptible);
181
- return next.accessToken;
182
- });
183
- // Typed failures invalidate locally. Interruption leaves the marker; defects are not auth failures.
184
- return yield* refresh.pipe(Effect.tapError(() => store.delete(id)));
185
- }));
186
- });
187
- const login = Effect.fn("BrowserSession.login")(function* (returnTo) {
188
- const target = yield* Effect.try({
189
- try: () => new URL(returnTo, origin),
190
- catch: () => new InvalidRequest({ message: "Invalid return destination" }),
191
- });
192
- if (!returnTo.startsWith("/") ||
193
- target.origin !== origin ||
194
- target.pathname === callbackUrl.pathname)
195
- return yield* new InvalidRequest({ message: "Invalid return destination" });
196
- const as = yield* provider;
197
- const raw = random();
198
- const transaction = {
199
- verifier: oauth.generateRandomCodeVerifier(),
200
- state: oauth.generateRandomState(),
201
- nonce: oauth.generateRandomNonce(),
202
- returnTo: target.pathname + target.search + target.hash,
203
- };
204
- const challenge = yield* pkceChallenge(transaction.verifier);
205
- const authorization = new URL(as.authorization_endpoint);
206
- authorization.search = new URLSearchParams({
207
- client_id: options.clientId,
208
- redirect_uri: redirectUri,
209
- response_type: "code",
210
- scope: ["openid", "offline_access", ...options.scopes].join(" "),
211
- resource: options.resource,
212
- state: transaction.state,
213
- nonce: transaction.nonce,
214
- code_challenge: challenge,
215
- code_challenge_method: "S256",
216
- }).toString();
217
- const now = yield* Clock.currentTimeMillis;
218
- yield* store.sweep(now);
219
- yield* write(`login:${hash(raw)}`, transaction, now + transactionLifetime * 1000);
220
- return { url: authorization.href, transaction: raw };
221
- });
222
- const callback = Effect.fn("BrowserSession.callback")(function* (url, raw) {
223
- if (url.searchParams.getAll("iss").length !== 1 ||
224
- url.searchParams.get("iss") !== options.issuer ||
225
- !raw)
226
- return yield* unauthorized();
227
- const id = `login:${hash(raw)}`;
228
- return yield* locked(id, Effect.gen(function* () {
229
- const row = yield* read(id);
230
- // One-shot consume precedes every code exchange, including retries after interruption.
231
- yield* store.delete(id).pipe(Effect.uninterruptible);
232
- const transaction = yield* decodeLogin(row.payload);
233
- const as = yield* provider;
234
- const parameters = yield* Effect.try({
235
- try: () => oauth.validateAuthResponse(as, client, url, transaction.state),
236
- catch: unauthorized,
237
- });
238
- const result = yield* protocol("browser.callback", async (opts) => {
239
- const response = await oauth.authorizationCodeGrantRequest(as, client, clientAuth, parameters, redirectUri, transaction.verifier, { ...opts, additionalParameters: { resource: options.resource } });
240
- const result = await oauth.processAuthorizationCodeResponse(as, client, response, {
241
- expectedNonce: transaction.nonce,
242
- requireIdToken: true,
243
- });
244
- await oauth.validateApplicationLevelSignature(as, response, opts);
245
- return result;
246
- });
247
- const saved = yield* credentials(result);
248
- if (oauth.getValidatedIdTokenClaims(result)?.sub !== saved.subject)
249
- return yield* unauthorized();
250
- const session = random();
251
- yield* write(`session:${hash(session)}`, saved, (yield* Clock.currentTimeMillis) + lifetime * 1000);
252
- return { session, location: origin + transaction.returnTo };
253
- }));
254
- });
255
- const session = Effect.fn("BrowserSession.session")(function* (raw) {
256
- const token = yield* accessToken(raw);
257
- const principal = yield* options.verifyToken(token);
258
- return {
259
- subject: principal.subject,
260
- scopes: principal.scopes,
261
- issuer: options.issuer,
262
- };
263
- });
264
- const logout = Effect.fn("BrowserSession.logout")(function* (raw) {
265
- if (!raw)
266
- return;
267
- const id = `session:${hash(raw)}`;
268
- yield* locked(id, Effect.gen(function* () {
269
- const row = yield* store.get(id);
270
- yield* store.delete(id).pipe(Effect.uninterruptible);
271
- if (!row)
272
- return;
273
- const saved = yield* decodeCredentials(row.payload);
274
- if (!saved.refreshToken)
275
- return;
276
- const token = saved.refreshToken;
277
- // Local deletion is authoritative. Provider revocation is deliberately best effort.
278
- yield* Effect.gen(function* () {
279
- const as = yield* provider;
280
- yield* protocol("browser.revoke", async (opts) => oauth.processRevocationResponse(await oauth.revocationRequest(as, client, clientAuth, token, {
281
- ...opts,
282
- additionalParameters: { token_type_hint: "refresh_token" },
283
- })));
284
- }).pipe(Effect.catch(() => Effect.logWarning("Browser session ended locally; provider revocation failed")));
285
- }));
286
- });
287
- return {
288
- login,
289
- callback,
290
- session,
291
- logout,
292
- accessToken,
293
- origin,
294
- // SAFETY: the validated HTTP(S) URL has an absolute pathname beginning with "/".
295
- callbackPath: callbackUrl.pathname,
296
- resource: options.resource,
297
- issuer: options.issuer,
298
- cookie: { name: options.cookie.name, lifetime, secure: callbackUrl.protocol === "https:" },
299
- checkOrigin: (value) => value === origin
300
- ? Effect.void
301
- : Effect.fail(new Forbidden({ message: "Cross-origin request rejected" })),
302
- };
303
- });
@@ -1,3 +0,0 @@
1
- import { Effect } from "effect";
2
- /** Reference counts include waiters, so an interrupted waiter cannot split a live lock. */
3
- export declare const make: Effect.Effect<<A, E, R>(id: string, action: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>, never, never>;
@@ -1,18 +0,0 @@
1
- import { Effect, Semaphore } from "effect";
2
- /** Reference counts include waiters, so an interrupted waiter cannot split a live lock. */
3
- export const make = Effect.sync(() => {
4
- const entries = new Map();
5
- return (id, action) => Effect.acquireUseRelease(Effect.sync(() => {
6
- let entry = entries.get(id);
7
- if (!entry) {
8
- entry = { semaphore: Semaphore.makeUnsafe(1), users: 0 };
9
- entries.set(id, entry);
10
- }
11
- entry.users++;
12
- return entry;
13
- }), (entry) => entry.semaphore.withPermit(action), (entry) => Effect.sync(() => {
14
- entry.users--;
15
- if (entry.users === 0)
16
- entries.delete(id);
17
- }));
18
- });
package/dist/store.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import { Context, Effect } from "effect";
2
- import type { StoreError } from "./errors.ts";
3
- export interface Row {
4
- readonly payload: string;
5
- readonly expires: number;
6
- }
7
- declare const SessionStore_base: Context.ServiceClass<SessionStore, "@clankerauth/SessionStore", {
8
- readonly get: (id: string) => Effect.Effect<Row | undefined, StoreError>;
9
- readonly put: (id: string, payload: string, expires: number) => Effect.Effect<void, StoreError>;
10
- readonly delete: (id: string) => Effect.Effect<void, StoreError>;
11
- readonly sweep: (now: number) => Effect.Effect<void, StoreError>;
12
- }>;
13
- /** Sealed rows. One process owns a store; the session capability serializes work per id. */
14
- export declare class SessionStore extends SessionStore_base {
15
- }
16
- export {};
package/dist/store.js DELETED
@@ -1,4 +0,0 @@
1
- import { Context, Effect } from "effect";
2
- /** Sealed rows. One process owns a store; the session capability serializes work per id. */
3
- export class SessionStore extends Context.Service()("@clankerauth/SessionStore") {
4
- }