Skip to content

Commit ba1bb54

Browse files
Nick Ficanoclaude
andcommitted
effect(core-auth): add BearerVerifierService and staticBearerVerifierLayer
Introduces an Effect.Service surface for bearer-token verification (tag arcp/BearerVerifierService, shape BearerVerifierEffect) alongside the legacy BearerVerifier interface and StaticBearerVerifier class, which stay byte-compatible for existing consumers (examples/, sdk/cli, runtime). The default provider rejects every token with TaggedUnauthenticated; staticBearerVerifierLayer(table) is the Effect-shaped replacement for new StaticBearerVerifier(table) and composes via Layer.succeed. Tests cover the happy path, unknown-token rejection, the no-config default, and the legacy Promise class. Closes #40 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1e7bf5 commit ba1bb54

4 files changed

Lines changed: 168 additions & 6 deletions

File tree

packages/core/src/auth/bearer.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
import { Effect, Layer, Option } from "effect";
2+
3+
import { TaggedUnauthenticated } from "../errors-tagged.js";
14
import { UnauthenticatedError } from "../errors.js";
25

3-
import type { BearerIdentity, BearerVerifier } from "./types.js";
6+
import type {
7+
BearerIdentity,
8+
BearerVerifier,
9+
BearerVerifierEffect,
10+
} from "./types.js";
411

512
/**
613
* In-memory bearer verifier suitable for tests and small deployments. Maps
714
* tokens to identities verbatim.
15+
*
16+
* Legacy Promise-shaped surface — preserved so consumers that instantiate
17+
* `new StaticBearerVerifier(new Map([...]))` keep compiling. Effect-native
18+
* callers should use {@link staticBearerVerifierLayer} or
19+
* {@link BearerVerifierService} instead.
820
*/
921
export class StaticBearerVerifier implements BearerVerifier {
1022
public constructor(
@@ -22,3 +34,58 @@ export class StaticBearerVerifier implements BearerVerifier {
2234
return identity;
2335
}
2436
}
37+
38+
/**
39+
* Effect-native bearer verifier service. The default provider rejects every
40+
* token with `TaggedUnauthenticated` — replace it via
41+
* {@link staticBearerVerifierLayer} or a user-supplied `Layer.succeed`
42+
* (e.g. for JOSE / JWKS-backed verification).
43+
*
44+
* @example
45+
* ```ts
46+
* const layer = staticBearerVerifierLayer(
47+
* new Map([["tok-good", { principal: "alice" }]]),
48+
* )
49+
* const program = Effect.gen(function* () {
50+
* const v = yield* BearerVerifierService
51+
* return yield* v.verify("tok-good")
52+
* }).pipe(Effect.provide(layer))
53+
* ```
54+
*/
55+
export class BearerVerifierService extends Effect.Service<BearerVerifierService>()(
56+
"arcp/BearerVerifierService",
57+
{
58+
succeed: {
59+
verify: (
60+
_token: string,
61+
): Effect.Effect<BearerIdentity, TaggedUnauthenticated> =>
62+
Effect.fail(
63+
new TaggedUnauthenticated({
64+
message: "no bearer verifier configured",
65+
}),
66+
),
67+
} satisfies BearerVerifierEffect,
68+
},
69+
) {}
70+
71+
/**
72+
* Build a {@link BearerVerifierService} layer backed by a static token →
73+
* identity table. Equivalent of `new StaticBearerVerifier(table)` for
74+
* Effect-shaped pipelines.
75+
*/
76+
export function staticBearerVerifierLayer(
77+
table: ReadonlyMap<string, BearerIdentity>,
78+
): Layer.Layer<BearerVerifierService> {
79+
const impl: BearerVerifierEffect = {
80+
verify: (token) =>
81+
Option.fromNullable(table.get(token)).pipe(
82+
Effect.mapError(
83+
() => new TaggedUnauthenticated({ message: "Unknown bearer token" }),
84+
),
85+
),
86+
};
87+
return Layer.succeed(
88+
BearerVerifierService,
89+
BearerVerifierService.make(impl),
90+
);
91+
}

packages/core/src/auth/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
1-
export { StaticBearerVerifier } from "./bearer.js";
2-
export type { BearerIdentity, BearerVerifier } from "./types.js";
1+
export {
2+
BearerVerifierService,
3+
StaticBearerVerifier,
4+
staticBearerVerifierLayer,
5+
} from "./bearer.js";
6+
export type {
7+
BearerIdentity,
8+
BearerVerifier,
9+
BearerVerifierEffect,
10+
} from "./types.js";

packages/core/src/auth/types.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type { Effect } from "effect";
2+
3+
import type { TaggedUnauthenticated } from "../errors-tagged.js";
4+
15
/**
26
* Outcome of authenticating a bearer credential. The runtime uses the
37
* principal to scope identity; entitlements drive subscription authorization.
@@ -11,10 +15,26 @@ export interface BearerIdentity {
1115
}
1216

1317
/**
14-
* Bearer-token verifier. Implementers consult their trust store; the protocol
15-
* leaves issuance up to the deployment per §2 (auth provider implementations
16-
* are out of scope).
18+
* Bearer-token verifier (legacy Promise-shaped). Implementers consult their
19+
* trust store; the protocol leaves issuance up to the deployment per §2 (auth
20+
* provider implementations are out of scope).
21+
*
22+
* Retained as a published interface so existing consumers (including
23+
* `examples/custom-auth`) continue to compile. Effect-native consumers should
24+
* prefer `BearerVerifierEffect` and `BearerVerifierService` from
25+
* `./bearer.js`.
1726
*/
1827
export interface BearerVerifier {
1928
verify(token: string): Promise<BearerIdentity>;
2029
}
30+
31+
/**
32+
* Effect-shaped verifier surface. The `verify` method returns an Effect that
33+
* fails with `TaggedUnauthenticated` when the token is unknown or invalid.
34+
* Used as the operational shape of `BearerVerifierService`.
35+
*/
36+
export interface BearerVerifierEffect {
37+
readonly verify: (
38+
token: string,
39+
) => Effect.Effect<BearerIdentity, TaggedUnauthenticated>;
40+
}

packages/core/test/auth.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Effect, Exit } from "effect";
2+
import { describe, expect, it } from "vitest";
3+
4+
import {
5+
type BearerIdentity,
6+
BearerVerifierService,
7+
StaticBearerVerifier,
8+
staticBearerVerifierLayer,
9+
TaggedUnauthenticated,
10+
UnauthenticatedError,
11+
} from "@arcp/core";
12+
13+
const ALICE: BearerIdentity = { principal: "alice" };
14+
15+
describe("staticBearerVerifierLayer (Effect surface)", () => {
16+
const table = new Map<string, BearerIdentity>([["tok-good", ALICE]]);
17+
const layer = staticBearerVerifierLayer(table);
18+
19+
it("verifies a known token", async () => {
20+
const program = Effect.gen(function* () {
21+
const v = yield* BearerVerifierService;
22+
return yield* v.verify("tok-good");
23+
}).pipe(Effect.provide(layer));
24+
const identity = await Effect.runPromise(program);
25+
expect(identity).toEqual(ALICE);
26+
});
27+
28+
it("fails with TaggedUnauthenticated for unknown tokens", async () => {
29+
const program = Effect.gen(function* () {
30+
const v = yield* BearerVerifierService;
31+
return yield* v.verify("tok-bad");
32+
}).pipe(Effect.provide(layer));
33+
const exit = await Effect.runPromiseExit(program);
34+
expect(Exit.isFailure(exit)).toBe(true);
35+
if (Exit.isFailure(exit)) {
36+
const err = exit.cause._tag === "Fail" ? exit.cause.error : undefined;
37+
expect(err).toBeInstanceOf(TaggedUnauthenticated);
38+
}
39+
});
40+
});
41+
42+
describe("BearerVerifierService default provider", () => {
43+
it("rejects every token until a layer is supplied", async () => {
44+
const program = Effect.gen(function* () {
45+
const v = yield* BearerVerifierService;
46+
return yield* v.verify("anything");
47+
}).pipe(Effect.provide(BearerVerifierService.Default));
48+
const exit = await Effect.runPromiseExit(program);
49+
expect(Exit.isFailure(exit)).toBe(true);
50+
});
51+
});
52+
53+
describe("StaticBearerVerifier (legacy Promise surface)", () => {
54+
it("resolves known tokens to the identity", async () => {
55+
const v = new StaticBearerVerifier(
56+
new Map([["tok-good", ALICE]]),
57+
);
58+
await expect(v.verify("tok-good")).resolves.toEqual(ALICE);
59+
});
60+
61+
it("throws UnauthenticatedError for unknown tokens", async () => {
62+
const v = new StaticBearerVerifier(new Map());
63+
await expect(v.verify("nope")).rejects.toBeInstanceOf(
64+
UnauthenticatedError,
65+
);
66+
});
67+
});

0 commit comments

Comments
 (0)