@effected/oidc 0.2.0 → 0.3.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/Oidc.js CHANGED
@@ -34,13 +34,15 @@ const verify = Effect.fn("Oidc.verify")(function* (token, options) {
34
34
  *
35
35
  * @remarks
36
36
  * Both operations fail with `@effected/jwt`'s `JwtError` and add no error of
37
- * their own; route on its `reason`, never on its message.
37
+ * their own; route on its `reason`, never on its message. Verification runs
38
+ * in core `effect/jose` and requires core's `Signature` service.
38
39
  *
39
40
  * @example
40
41
  * ```ts
41
42
  * import { JwksResolver } from "@effected/jwt";
42
43
  * import { IdTokenClaims, Oidc } from "@effected/oidc";
43
- * import { Effect } from "effect";
44
+ * import { Effect, Layer } from "effect";
45
+ * import { SecureRandom, Signature } from "effect/crypto";
44
46
  *
45
47
  * const program = Oidc.verify("eyJ...", {
46
48
  * issuer: "https://accounts.example.com",
@@ -49,7 +51,8 @@ const verify = Effect.fn("Oidc.verify")(function* (token, options) {
49
51
  * });
50
52
  *
51
53
  * // JwksResolver.layer needs an HttpClient and a JwksStore, provided once at the edge.
52
- * export const main = program.pipe(Effect.provide(JwksResolver.layer));
54
+ * const CryptoLive = Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));
55
+ * export const main = program.pipe(Effect.provide(Layer.merge(JwksResolver.layer, CryptoLive)));
53
56
  * ```
54
57
  *
55
58
  * @public
@@ -66,7 +69,8 @@ var Oidc = class {
66
69
  * authorizing anything: the result is whatever the token's author wrote.
67
70
  *
68
71
  * Fails as `malformed` when the token is not a compact JWS with a JOSE
69
- * header, and as `claims` when the payload does not decode.
72
+ * header and a JSON-object payload, and as `claims` when the payload does
73
+ * not decode. Needs no service: nothing is verified.
70
74
  *
71
75
  * @public
72
76
  */
@@ -80,7 +84,8 @@ var Oidc = class {
80
84
  * the signature against the issuer's discovered JWKS, then `exp`, `nbf`,
81
85
  * `iat`, `iss` and `aud`, then the schema decode. An empty audience, or an
82
86
  * empty string among several, fails as `wrongAudience` before anything
83
- * else, with no network call.
87
+ * else, with no network call. Requires core's `Signature` service alongside
88
+ * `JwksResolver`.
84
89
  *
85
90
  * The key is always looked up for the configured issuer, never for the
86
91
  * token's own `iss`, so a token naming another issuer is never checked
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
1
  # @effected/oidc
2
2
 
3
- OpenID Connect for Effect v4: the IdentityToken issuer contract, ID-token claims, and a discovery-backed verify over @effected/jwt.
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Foidc?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/oidc)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+
7
+ OpenID Connect for Effect v4: verify an ID token against its issuer's discovered keys, read the standard ID-token claims, and ask for a workload identity token through one small contract that any runtime can serve. It runs anywhere WebCrypto does, Cloudflare Workers included, with no `node:` import.
8
+
9
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on
10
+ > stable Effect v4 and still in `0.x` development. To keep your `effect` and
11
+ > `@effect/*` versions on the line the kit is built and tested against, install
12
+ > [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
13
+ >
14
+ > **Stability: unstable.** This package's API surface is not yet considered
15
+ > complete and may change across `0.x` releases. Pin an exact version. Full
16
+ > policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
17
+
18
+ ## Why @effected/oidc
19
+
20
+ Verifying an OIDC ID token is a JWT verification with three rules that are easy to get wrong: the keys come from the issuer's discovery document, the issuer you trust is the one you configured rather than the one the token claims, and the audience must always be checked, because an issuer's one key set signs tokens for every relying party. `Oidc.verify` holds all three: it looks keys up for the configured issuer only, and it refuses an empty audience before making any request.
21
+
22
+ The signature and claim checks run on core `effect/jose`, through [`@effected/jwt`](https://www.npmjs.com/package/@effected/jwt)'s discovery-backed `JwksResolver`. If you hold the issuer's key, or its JWKS URL is fixed in your configuration, core `effect/jose` (`Jwt.verify` with `Jwks.layerRemote`) does the job without this package. Reach for `Oidc.verify` when the keys are found by discovery, when you want `@effected/jwt`'s safer defaults (60 seconds of clock skew, `exp` required), or when you want `IdTokenClaims` and the `IdentityToken` contract.
23
+
24
+ For a GitHub Actions OIDC token, use `ActionsOidc.verify` from [`@effected/github`](https://www.npmjs.com/package/@effected/github), which is `Oidc.verify` pinned to GitHub's issuer with typed claims.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install @effected/oidc effect
30
+ ```
31
+
32
+ ```bash
33
+ pnpm add @effected/oidc effect
34
+ ```
35
+
36
+ On Node, requires Node.js >=24.11.0; it also runs on any other WebCrypto runtime, Cloudflare Workers included. `effect` v4 is the only peer, and `@effected/jwt` comes with it. Import `JwksResolver` and `JwksStore` from `@effected/jwt`. All `@effected/*` packages are ESM-only.
37
+
38
+ ## What to provide
39
+
40
+ | Operation | Requires |
41
+ | --- | --- |
42
+ | `Oidc.verify` | `JwksResolver` (from `@effected/jwt`) and `Signature.Signature` (from `effect/crypto`) |
43
+ | `Oidc.decodeUnverified` | nothing |
44
+ | a program using `IdentityToken` | an `IdentityToken` layer (below) |
45
+
46
+ | Service | Any WebCrypto runtime (Workers included) | Node |
47
+ | --- | --- | --- |
48
+ | `Signature.Signature` | `Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto))` | `NodeCrypto.layerSignature` from `@effect/platform-node` |
49
+ | `JwksResolver` | `JwksResolver.layer`, over an `HttpClient` (`FetchHttpClient.layer`) and a `JwksStore` (`JwksStore.layerMemory`, or a KV-backed store) | the same, or `NodeHttpClient.layerUndici` for the client |
50
+ | `IdentityToken` | `IdentityToken.layerStatic(token)`; in GitHub Actions, `ActionsIdentityToken.layer` from `@effected/github-actions` | the same |
51
+
52
+ ```ts
53
+ import { JwksResolver, JwksStore } from "@effected/jwt";
54
+ import { Layer } from "effect";
55
+ import { SecureRandom, Signature } from "effect/crypto";
56
+ import { FetchHttpClient } from "effect/http";
57
+
58
+ const OidcLive = Layer.merge(
59
+ JwksResolver.layer.pipe(Layer.provide(Layer.merge(FetchHttpClient.layer, JwksStore.layerMemory))),
60
+ Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)),
61
+ );
62
+ ```
63
+
64
+ ## Verify an ID token
65
+
66
+ ```ts
67
+ import { IdTokenClaims, Oidc } from "@effected/oidc";
68
+
69
+ const verifyIdToken = (token: string) =>
70
+ Oidc.verify(token, {
71
+ issuer: "https://accounts.example.com",
72
+ audience: "my-client-id",
73
+ claims: IdTokenClaims,
74
+ });
75
+ // Effect<IdTokenClaims, JwtError, JwksResolver | Signature>
76
+ ```
77
+
78
+ The keys are discovered from `<issuer>/.well-known/openid-configuration`, and the discovery document must name that issuer exactly. Then come the signature, `exp` (required), `iss` (must equal `issuer`), `aud` (must contain one of `audience`, which may be a list), `nbf`, your `claims` schema, and `iat` not in the future, with 60 seconds of clock skew allowed (`clockTolerance` changes it).
79
+
80
+ ## The ID-token claims
81
+
82
+ `IdTokenClaims` is the registered JWT claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`) plus the OpenID Connect ID-token claims `azp`, `nonce`, `auth_time`, `acr` and `amr`. Every claim is optional, so it decodes any provider's token, and the schema is open, so claims it does not name survive the decode. Optional in the schema does not mean optional in a verified token: `Oidc.verify` still requires `exp` and checks `iss` and `aud`.
83
+
84
+ A provider's own claims belong in your own schema, built on `IdTokenClaims`'s fields:
85
+
86
+ ```ts
87
+ import { IdTokenClaims, Oidc } from "@effected/oidc";
88
+ import { Schema } from "effect";
89
+
90
+ const GoogleClaims = Schema.StructWithRest(
91
+ Schema.Struct({
92
+ ...IdTokenClaims.schema.fields,
93
+ email: Schema.String,
94
+ email_verified: Schema.Boolean,
95
+ }),
96
+ [Schema.Record(Schema.String, Schema.Unknown)],
97
+ );
98
+
99
+ const verifyGoogle = (token: string) =>
100
+ Oidc.verify(token, { issuer: "https://accounts.google.com", audience: "my-client-id", claims: GoogleClaims });
101
+ ```
102
+
103
+ `Oidc.decodeUnverified(token, claims)` decodes a token's payload with a schema **without verifying anything**. It is for reading a token your process was handed by a runtime it already trusts, never for authorizing a request.
104
+
105
+ ## The `IdentityToken` contract
106
+
107
+ `IdentityToken` is the other direction: a program that needs to *present* a workload identity (to exchange it for a signing certificate, say) asks for one token for one audience, and the runtime decides how to get it.
108
+
109
+ ```ts
110
+ import { IdentityToken } from "@effected/oidc";
111
+ import { Effect, Redacted } from "effect";
112
+
113
+ const presentIdentity = Effect.gen(function* () {
114
+ const identity = yield* IdentityToken;
115
+ const token = yield* identity.token("https://my-service.example");
116
+ return Redacted.value(token);
117
+ });
118
+
119
+ // A token you already hold. The audience is ignored: mint it for the audience it will be used with.
120
+ const program = presentIdentity.pipe(Effect.provide(IdentityToken.layerStatic(process.env.OIDC_TOKEN ?? "")));
121
+ ```
122
+
123
+ In GitHub Actions, provide `ActionsIdentityToken.layer` from `@effected/github-actions`, which asks the runner's token service. `IdentityToken` itself imports nothing but `effect`, so a package that only needs the contract links no JWT code.
124
+
125
+ ## Errors
126
+
127
+ `Oidc.verify` and `Oidc.decodeUnverified` fail with `@effected/jwt`'s `JwtError` and add no error of their own. Route on `reason`:
128
+
129
+ | `reason` | Meaning |
130
+ | --- | --- |
131
+ | `wrongAudience` | `aud` contains none of the accepted audiences, or is missing; also an empty `audience` option, refused before any request. |
132
+ | `wrongIssuer` | `iss` is missing or is not the configured issuer. |
133
+ | `expired` / `notYetValid` | `exp` has passed, or `nbf` or `iat` is in the future, beyond the clock tolerance. |
134
+ | `claims` | `exp` is missing, a registered claim has the wrong type, or your `claims` schema refused the payload. |
135
+ | `badSignature` | The signature does not verify against the issuer's key. |
136
+ | `unknownKid` | The issuer's key set has no key for the token's algorithm and `kid`, even after one refetch. |
137
+ | `jwksFetch` | The discovery document or key set could not be fetched, was not `https:`, or named another issuer. |
138
+ | `malformed` | Not a compact JWS with a JSON-object payload, or `alg: "none"`. |
139
+ | `unsupportedAlgorithm` / `algorithmMismatch` / `key` | An HMAC algorithm, an `alg` that is not the key's, or a published key that will not import. |
140
+
141
+ `IdentityToken.token` fails with `IdentityTokenError`, carrying the `audience` the token was requested for and the underlying `cause`.
142
+
143
+ ## Testing
144
+
145
+ `IdentityToken.layerTest()` answers every request with a fixed test token; pass `{ token }` to answer differently. To test `Oidc.verify`, sign with `TestIssuer` from `@effected/jwt/testing` and provide its `resolverLayer`, the real resolver over an in-memory issuer:
146
+
147
+ ```ts
148
+ import { TestIssuer } from "@effected/jwt/testing";
149
+ import { IdTokenClaims, Oidc } from "@effected/oidc";
150
+ import { Effect } from "effect";
151
+
152
+ const test = Effect.gen(function* () {
153
+ const issuer = yield* TestIssuer.make({ issuer: "https://accounts.example.com" });
154
+ const token = yield* issuer.sign({ aud: "my-client-id", sub: "user-1" });
155
+ return yield* Oidc.verify(token, {
156
+ issuer: "https://accounts.example.com",
157
+ audience: "my-client-id",
158
+ claims: IdTokenClaims,
159
+ }).pipe(Effect.provide(issuer.resolverLayer));
160
+ });
161
+ // provide Signature (see "What to provide") around the test
162
+ ```
163
+
164
+ ## License
165
+
166
+ [MIT](LICENSE)
package/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Context, Duration, Effect, Layer, Redacted, Schema } from "effect";
2
2
  import { JwksResolver, JwtError } from "@effected/jwt";
3
+ import { Signature } from "effect/crypto";
3
4
  declare const IdentityTokenError_base: Schema.Class<IdentityTokenError, Schema.TaggedStruct<"IdentityTokenError", {
4
5
  /** The audience the token was requested for. */
5
6
  readonly audience: Schema.String;
@@ -154,13 +155,15 @@ interface OidcVerifyOptions<S extends Schema.Constraint> {
154
155
  *
155
156
  * @remarks
156
157
  * Both operations fail with `@effected/jwt`'s `JwtError` and add no error of
157
- * their own; route on its `reason`, never on its message.
158
+ * their own; route on its `reason`, never on its message. Verification runs
159
+ * in core `effect/jose` and requires core's `Signature` service.
158
160
  *
159
161
  * @example
160
162
  * ```ts
161
163
  * import { JwksResolver } from "@effected/jwt";
162
164
  * import { IdTokenClaims, Oidc } from "@effected/oidc";
163
- * import { Effect } from "effect";
165
+ * import { Effect, Layer } from "effect";
166
+ * import { SecureRandom, Signature } from "effect/crypto";
164
167
  *
165
168
  * const program = Oidc.verify("eyJ...", {
166
169
  * issuer: "https://accounts.example.com",
@@ -169,7 +172,8 @@ interface OidcVerifyOptions<S extends Schema.Constraint> {
169
172
  * });
170
173
  *
171
174
  * // JwksResolver.layer needs an HttpClient and a JwksStore, provided once at the edge.
172
- * export const main = program.pipe(Effect.provide(JwksResolver.layer));
175
+ * const CryptoLive = Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));
176
+ * export const main = program.pipe(Effect.provide(Layer.merge(JwksResolver.layer, CryptoLive)));
173
177
  * ```
174
178
  *
175
179
  * @public
@@ -186,7 +190,8 @@ export declare class Oidc {
186
190
  * authorizing anything: the result is whatever the token's author wrote.
187
191
  *
188
192
  * Fails as `malformed` when the token is not a compact JWS with a JOSE
189
- * header, and as `claims` when the payload does not decode.
193
+ * header and a JSON-object payload, and as `claims` when the payload does
194
+ * not decode. Needs no service: nothing is verified.
190
195
  *
191
196
  * @public
192
197
  */
@@ -200,7 +205,8 @@ export declare class Oidc {
200
205
  * the signature against the issuer's discovered JWKS, then `exp`, `nbf`,
201
206
  * `iat`, `iss` and `aud`, then the schema decode. An empty audience, or an
202
207
  * empty string among several, fails as `wrongAudience` before anything
203
- * else, with no network call.
208
+ * else, with no network call. Requires core's `Signature` service alongside
209
+ * `JwksResolver`.
204
210
  *
205
211
  * The key is always looked up for the configured issuer, never for the
206
212
  * token's own `iss`, so a token naming another issuer is never checked
@@ -208,7 +214,7 @@ export declare class Oidc {
208
214
  *
209
215
  * @public
210
216
  */
211
- static readonly verify: <S extends Schema.Constraint>(token: string, options: OidcVerifyOptions<S>) => Effect.Effect<S["Type"], JwtError, JwksResolver | S["DecodingServices"]>;
217
+ static readonly verify: <S extends Schema.Constraint>(token: string, options: OidcVerifyOptions<S>) => Effect.Effect<S["Type"], JwtError, JwksResolver | Signature.Signature | S["DecodingServices"]>;
212
218
  }
213
219
  export type { IdentityTokenShape, OidcVerifyOptions };
214
220
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/oidc",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "OpenID Connect for Effect v4: the IdentityToken issuer contract, ID-token claims, and a discovery-backed verify over @effected/jwt.",
6
6
  "keywords": [
@@ -38,10 +38,10 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "@effected/jwt": "^0.7.0"
41
+ "@effected/jwt": "^0.8.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "effect": "^4.0.0"
44
+ "effect": "~4.1.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.11.0"
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.4"
8
+ "packageVersion": "7.60.0"
9
9
  }
10
10
  ]
11
11
  }