@effect-app/infra 4.0.0-beta.122 → 4.0.0-beta.124

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.
@@ -1,42 +1,244 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- /* eslint-disable unused-imports/no-unused-vars */
3
- import { Data, Effect } from "effect-app"
1
+ import { Data, Effect, Option } from "effect-app"
4
2
  import { HttpHeaders, HttpMiddleware, HttpServerRequest, HttpServerResponse } from "effect-app/http"
5
- import { auth, InsufficientScopeError, InvalidRequestError, InvalidTokenError, UnauthorizedError } from "express-oauth2-jwt-bearer"
3
+ import { createRemoteJWKSet, jwtVerify } from "jose"
6
4
 
7
- // // Authorization middleware. When used, the Access Token must
8
- // // exist and be verified against the Auth0 JSON Web Key Set.
5
+ const getHeaders = (error: string, description: string, scopes?: ReadonlyArray<string>) => ({
6
+ "WWW-Authenticate": `Bearer realm="api", error="${error}", error_description="${description.replace(/"/g, "'")}"${
7
+ scopes ? `, scope="${scopes.join(" ")}"` : ""
8
+ }`
9
+ })
9
10
 
10
- // type Errors = InsufficientScopeError | InvalidRequestError | InvalidTokenError | UnauthorizedError
11
- type Config = Parameters<typeof auth>[0]
12
- export const checkJWTI = (config: Config) => {
13
- const mw = auth(config)
14
- return Effect.fnUntraced(function*(headers: HttpHeaders.Headers) {
15
- return yield* Effect.callback<
16
- void,
17
- InsufficientScopeError | InvalidRequestError | InvalidTokenError | UnauthorizedError
18
- >(
19
- (resume) => {
20
- const next = (err?: unknown) => {
21
- if (!err) return resume(Effect.void)
22
- if (
23
- err instanceof InsufficientScopeError
24
- || err instanceof InvalidRequestError
25
- || err instanceof InvalidTokenError
26
- || err instanceof UnauthorizedError
27
- ) {
28
- return resume(Effect.fail(err))
11
+ export class UnauthorizedError extends Error {
12
+ readonly status: number = 401
13
+ readonly statusCode: number = 401
14
+ headers = { "WWW-Authenticate": "Bearer realm=\"api\"" }
15
+
16
+ constructor(message = "Unauthorized") {
17
+ super(message)
18
+ this.name = this.constructor.name
19
+ }
20
+ }
21
+
22
+ export class InvalidRequestError extends UnauthorizedError {
23
+ readonly code: string
24
+ override readonly status = 400
25
+ override readonly statusCode = 400
26
+
27
+ constructor(message = "Invalid Request", useErrorCode = true) {
28
+ super(message)
29
+ this.code = useErrorCode ? "invalid_request" : ""
30
+ if (useErrorCode) {
31
+ this.headers = getHeaders(this.code, this.message)
32
+ }
33
+ }
34
+ }
35
+
36
+ export class InvalidTokenError extends UnauthorizedError {
37
+ readonly code = "invalid_token"
38
+
39
+ constructor(message = "Invalid Token") {
40
+ super(message)
41
+ this.headers = getHeaders(this.code, this.message)
42
+ }
43
+ }
44
+
45
+ export class InsufficientScopeError extends UnauthorizedError {
46
+ readonly code = "insufficient_scope"
47
+ override readonly status = 403
48
+ override readonly statusCode = 403
49
+
50
+ constructor(scopes?: ReadonlyArray<string>, message = "Insufficient Scope") {
51
+ super(message)
52
+ this.headers = getHeaders(this.code, this.message, scopes)
53
+ }
54
+ }
55
+
56
+ export interface JwtVerifierOptions {
57
+ readonly audience?: string | Array<string> | ReadonlyArray<string>
58
+ readonly clockTolerance?: number
59
+ readonly issuer?: string
60
+ readonly issuerBaseURL?: string
61
+ readonly jwksUri?: string
62
+ readonly maxTokenAge?: number
63
+ readonly secret?: string
64
+ readonly strict?: boolean
65
+ readonly tokenSigningAlg?: string
66
+ }
67
+
68
+ export interface AuthOptions extends JwtVerifierOptions {
69
+ readonly authRequired?: boolean
70
+ }
71
+
72
+ type Config = AuthOptions
73
+
74
+ type JwtError = InsufficientScopeError | InvalidRequestError | InvalidTokenError | UnauthorizedError
75
+
76
+ type ResolvedConfigBase = {
77
+ readonly audience: string | Array<string> | undefined
78
+ readonly clockTolerance: number
79
+ readonly issuer: string | undefined
80
+ readonly maxTokenAge: number | undefined
81
+ readonly strict: boolean
82
+ readonly tokenSigningAlg: string | undefined
83
+ }
84
+
85
+ type ResolvedConfig =
86
+ & ResolvedConfigBase
87
+ & (
88
+ | {
89
+ readonly key: ReturnType<typeof createRemoteJWKSet>
90
+ readonly keyType: "jwks"
91
+ }
92
+ | {
93
+ readonly key: Uint8Array
94
+ readonly keyType: "secret"
95
+ }
96
+ )
97
+
98
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
99
+
100
+ const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
101
+
102
+ const normalizeAudience = (audience: Config["audience"]): string | Array<string> | undefined =>
103
+ Array.isArray(audience) ? Array.from(audience) : audience as string | undefined
104
+
105
+ const buildDiscoveryUrl = (issuerBaseURL: string) => {
106
+ const url = new URL(issuerBaseURL)
107
+ if (!url.pathname.includes("/.well-known/")) {
108
+ url.pathname = url.pathname.endsWith("/")
109
+ ? `${url.pathname}.well-known/openid-configuration`
110
+ : `${url.pathname}/.well-known/openid-configuration`
111
+ }
112
+ url.search = ""
113
+ url.hash = ""
114
+ return url
115
+ }
116
+
117
+ const fetchDiscoveryDocumentPromise = async (issuerBaseURL: string) => {
118
+ const response = await fetch(buildDiscoveryUrl(issuerBaseURL))
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to fetch authorization server metadata: ${response.status}`)
121
+ }
122
+ const json = await response.json()
123
+ if (!isRecord(json) || typeof json["issuer"] !== "string" || typeof json["jwks_uri"] !== "string") {
124
+ throw new Error("Invalid authorization server metadata")
125
+ }
126
+ return { issuer: json["issuer"], jwksUri: json["jwks_uri"] }
127
+ }
128
+
129
+ const getAuthorizationToken = (headers: HttpHeaders.Headers, authRequired: boolean) => {
130
+ const authorization = HttpHeaders.get(headers, "authorization")
131
+ if (Option.isNone(authorization)) {
132
+ return authRequired ? Effect.fail(new UnauthorizedError()) : Effect.succeed(Option.none<string>())
133
+ }
134
+
135
+ const [scheme, token] = authorization.value.split(" ")
136
+ if (!scheme || !token || scheme.toLowerCase() !== "bearer") {
137
+ return Effect.fail(new InvalidRequestError("", false))
138
+ }
139
+
140
+ return Effect.succeed(Option.some(token))
141
+ }
142
+
143
+ const makeResolveConfig = (config: Config) => {
144
+ let cached: Promise<ResolvedConfig> | undefined
145
+
146
+ return Effect.tryPromise({
147
+ try: () => {
148
+ if (!cached) {
149
+ cached = (async (): Promise<ResolvedConfig> => {
150
+ const discovery = config.issuerBaseURL
151
+ ? await fetchDiscoveryDocumentPromise(config.issuerBaseURL)
152
+ : undefined
153
+
154
+ const issuer = config.issuer ?? discovery?.issuer
155
+ const jwksUri = config.jwksUri ?? discovery?.jwksUri
156
+ const secret = config.secret
157
+ const base = {
158
+ audience: normalizeAudience(config.audience),
159
+ clockTolerance: config.clockTolerance ?? 5,
160
+ issuer,
161
+ maxTokenAge: config.maxTokenAge,
162
+ strict: config.strict ?? false,
163
+ tokenSigningAlg: config.tokenSigningAlg
164
+ } satisfies ResolvedConfigBase
165
+
166
+ if (!issuer && !secret) {
167
+ throw new InvalidRequestError("JWT config requires 'issuer', 'issuerBaseURL', or 'secret'")
29
168
  }
30
- return resume(Effect.die(err))
31
- }
32
- const r = { headers, query: {}, body: {}, is: () => false, method: "POST" } // is("urlencoded")
33
- try {
34
- mw(r as any, {} as any, next)
35
- } catch (e) {
36
- return resume(Effect.die(e))
37
- }
169
+
170
+ if (!secret) {
171
+ if (!jwksUri) {
172
+ throw new InvalidRequestError("JWT config requires 'jwksUri', 'issuerBaseURL', or 'secret'")
173
+ }
174
+
175
+ return {
176
+ ...base,
177
+ key: createRemoteJWKSet(new URL(jwksUri)),
178
+ keyType: "jwks"
179
+ }
180
+ }
181
+
182
+ return {
183
+ ...base,
184
+ key: new TextEncoder().encode(secret),
185
+ keyType: "secret"
186
+ }
187
+ })()
38
188
  }
189
+
190
+ return cached
191
+ },
192
+ catch: (error) =>
193
+ error instanceof InvalidRequestError || error instanceof InvalidTokenError
194
+ ? error
195
+ : new InvalidTokenError(getErrorMessage(error))
196
+ })
197
+ }
198
+
199
+ const verifyToken =
200
+ (resolveConfig: Effect.Effect<ResolvedConfig, InvalidRequestError | InvalidTokenError>) => (token: string) =>
201
+ resolveConfig.pipe(
202
+ Effect.flatMap((config) => {
203
+ const options = {
204
+ clockTolerance: config.clockTolerance,
205
+ ...(config.tokenSigningAlg ? { algorithms: [config.tokenSigningAlg] } : {}),
206
+ ...(config.audience !== undefined ? { audience: config.audience } : {}),
207
+ ...(config.issuer !== undefined ? { issuer: config.issuer } : {}),
208
+ ...(config.maxTokenAge !== undefined ? { maxTokenAge: config.maxTokenAge } : {})
209
+ }
210
+ const verified = config.keyType === "jwks"
211
+ ? Effect.tryPromise({
212
+ try: () => jwtVerify(token, config.key, options).then(({ protectedHeader }) => ({ protectedHeader })),
213
+ catch: (error) => new InvalidTokenError(getErrorMessage(error))
214
+ })
215
+ : Effect.tryPromise({
216
+ try: () => jwtVerify(token, config.key, options).then(({ protectedHeader }) => ({ protectedHeader })),
217
+ catch: (error) => new InvalidTokenError(getErrorMessage(error))
218
+ })
219
+
220
+ return verified.pipe(
221
+ Effect.flatMap(({ protectedHeader }) => {
222
+ const typ = protectedHeader.typ?.toLowerCase().replace(/^application\//, "")
223
+ return config.strict && typ !== "at+jwt"
224
+ ? Effect.fail(new InvalidTokenError("Unexpected 'typ' value"))
225
+ : Effect.void
226
+ })
227
+ )
228
+ })
39
229
  )
230
+
231
+ export const checkJWTI = (config: Config) => {
232
+ const resolveConfig = makeResolveConfig(config)
233
+ const verify = verifyToken(resolveConfig)
234
+
235
+ return Effect.fnUntraced(function*(headers: HttpHeaders.Headers) {
236
+ const token = yield* getAuthorizationToken(headers, config.authRequired !== false)
237
+ if (Option.isNone(token)) {
238
+ return
239
+ }
240
+
241
+ yield* verify(token.value)
40
242
  })
41
243
  }
42
244
 
@@ -46,25 +248,23 @@ export const checkJwt = (config: Config) => {
46
248
  Effect.gen(function*() {
47
249
  const req = yield* HttpServerRequest.HttpServerRequest
48
250
  const response = yield* check(req.headers).pipe(
49
- Effect.catch((e) =>
50
- HttpServerResponse.json({ message: e.message }, {
51
- status: e.status,
52
- headers: HttpHeaders.fromInput(e.headers)
251
+ Effect.catch((error: JwtError) =>
252
+ HttpServerResponse.json({ message: error.message }, {
253
+ status: error.status,
254
+ headers: HttpHeaders.fromInput(error.headers)
53
255
  })
54
256
  )
55
257
  )
258
+
56
259
  if (response) {
57
260
  return response
58
261
  }
262
+
59
263
  return yield* app
60
264
  })
61
265
  )
62
266
  }
63
267
 
64
268
  export class JWTError extends Data.TaggedClass("JWTError")<{
65
- error:
66
- | InsufficientScopeError
67
- | InvalidRequestError
68
- | InvalidTokenError
69
- | UnauthorizedError
269
+ error: JwtError
70
270
  }> {}
@@ -142,7 +142,7 @@ export const DefaultGenericMiddlewaresLive = Layer.mergeAll(
142
142
  * }) {}
143
143
  * ```
144
144
  */
145
- export const requiresTransactionConfig = RpcContextMap.makeCustom()(Schema.Never, false as boolean)
145
+ export const requiresTransactionConfig = RpcContextMap.makeCustom()(Schema.Never, false)
146
146
 
147
147
  /**
148
148
  * Creates the middleware Effect for SQL transaction wrapping.
@@ -249,7 +249,7 @@ export const makeRouter = <
249
249
  type RequestModules = FilterRequestModules<Resource>
250
250
  const requestModules = typedKeysOf(rsc).reduce((acc, cur) => {
251
251
  if (Predicate.isObjectKeyword(rsc[cur]) && rsc[cur]["success"]) {
252
- acc[cur as keyof RequestModules] = rsc[cur] as RequestModules[keyof RequestModules]
252
+ acc[cur as keyof RequestModules] = rsc[cur]
253
253
  }
254
254
  return acc
255
255
  }, {} as RequestModules)
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect } from "effect-app"
3
+ import { HttpHeaders } from "effect-app/http"
4
+ import { SignJWT } from "jose"
5
+ import { checkJWTI, InvalidRequestError, InvalidTokenError, UnauthorizedError } from "../src/api/internal/auth.js"
6
+
7
+ const issuer = "https://issuer.example.com/"
8
+ const audience = "effect-app"
9
+ const secret = "test-secret-test-secret-test-secret"
10
+
11
+ const makeToken = () =>
12
+ new SignJWT({ scope: "read:all" })
13
+ .setProtectedHeader({ alg: "HS256", typ: "at+jwt" })
14
+ .setIssuer(issuer)
15
+ .setAudience(audience)
16
+ .setIssuedAt()
17
+ .setExpirationTime("10m")
18
+ .sign(new TextEncoder().encode(secret))
19
+
20
+ describe("checkJWTI", () => {
21
+ it.effect(
22
+ "validates a bearer token from headers",
23
+ Effect.fnUntraced(function*() {
24
+ const token = yield* Effect.promise(() => makeToken())
25
+
26
+ yield* checkJWTI({
27
+ audience,
28
+ issuer,
29
+ secret,
30
+ strict: true,
31
+ tokenSigningAlg: "HS256"
32
+ })(HttpHeaders.fromRecordUnsafe({ authorization: `Bearer ${token}` }))
33
+ })
34
+ )
35
+
36
+ it.effect(
37
+ "fails on malformed authorization headers",
38
+ Effect.fnUntraced(function*() {
39
+ const error = yield* Effect.flip(
40
+ checkJWTI({
41
+ audience,
42
+ issuer,
43
+ secret,
44
+ tokenSigningAlg: "HS256"
45
+ })(HttpHeaders.fromRecordUnsafe({ authorization: "Basic abc" }))
46
+ )
47
+
48
+ expect(error).toBeInstanceOf(InvalidRequestError)
49
+ expect(error.status).toBe(400)
50
+ })
51
+ )
52
+
53
+ it.effect(
54
+ "fails when the token is missing",
55
+ Effect.fnUntraced(function*() {
56
+ const error = yield* Effect.flip(
57
+ checkJWTI({
58
+ audience,
59
+ issuer,
60
+ secret,
61
+ tokenSigningAlg: "HS256"
62
+ })(HttpHeaders.empty)
63
+ )
64
+
65
+ expect(error).toBeInstanceOf(UnauthorizedError)
66
+ expect(error.status).toBe(401)
67
+ })
68
+ )
69
+
70
+ it.effect(
71
+ "allows missing tokens when auth is optional",
72
+ Effect.fnUntraced(function*() {
73
+ yield* checkJWTI({
74
+ audience,
75
+ authRequired: false,
76
+ issuer,
77
+ secret,
78
+ tokenSigningAlg: "HS256"
79
+ })(HttpHeaders.empty)
80
+ })
81
+ )
82
+
83
+ it.effect(
84
+ "fails when the token signature is invalid",
85
+ Effect.fnUntraced(function*() {
86
+ const token = yield* Effect.promise(() => makeToken())
87
+
88
+ const error = yield* Effect.flip(
89
+ checkJWTI({
90
+ audience,
91
+ issuer,
92
+ secret: "wrong-secret-wrong-secret-wrong-secret",
93
+ tokenSigningAlg: "HS256"
94
+ })(HttpHeaders.fromRecordUnsafe({ authorization: `Bearer ${token}` }))
95
+ )
96
+
97
+ expect(error).toBeInstanceOf(InvalidTokenError)
98
+ expect(error.status).toBe(401)
99
+ })
100
+ )
101
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.test.d.ts","sourceRoot":"","sources":["../auth.test.ts"],"names":[],"mappings":""}