@ghuri/oauth 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +149 -2
- package/dist/chunk-IS4XX7EU.js +68 -0
- package/dist/chunk-IS4XX7EU.js.map +1 -0
- package/dist/chunk-OIBPPYBX.js +112 -0
- package/dist/chunk-OIBPPYBX.js.map +1 -0
- package/dist/chunk-TSKPVGEC.js +717 -0
- package/dist/chunk-TSKPVGEC.js.map +1 -0
- package/dist/index.d.ts +338 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/dist/rest.d.ts +18 -0
- package/dist/rest.js +33 -0
- package/dist/rest.js.map +1 -0
- package/dist/strategy-nxNiKJFS.d.ts +109 -0
- package/dist/testing.d.ts +68 -0
- package/dist/testing.js +191 -0
- package/dist/testing.js.map +1 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ghuri contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ghuri/oauth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Login building blocks for [Ghuri](https://github.com/ghurijs/ghuri), in the style of **Passport.js**: register
|
|
4
|
+
**strategies** and turn them into views with `authenticate()`. OAuth 2.0 (PKCE), OpenID Connect (discovery, ID token
|
|
5
|
+
validation), username/password, magic links and bearer tokens are built in; write your own for anything else. Runs on
|
|
6
|
+
Cloudflare Workers and Node with WebCrypto only.
|
|
7
|
+
|
|
8
|
+
For "Log in with GitHub / Google" without writing code, use [`@ghuri/socialauth`](https://github.com/ghurijs/socialauth),
|
|
9
|
+
which is built on this package.
|
|
10
|
+
|
|
11
|
+
> Status: alpha (ARCHITECTURE.md §17). Needs `@ghuri/core` and `@ghuri/auth` 0.1.0-alpha.1 or later.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @ghuri/oauth
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// src/accounts/strategies.ts
|
|
21
|
+
import { MagicLinkStrategy, OIDCStrategy, strategies } from '@ghuri/oauth'
|
|
22
|
+
import { User } from '@ghuri/auth'
|
|
23
|
+
|
|
24
|
+
strategies.use(
|
|
25
|
+
'acme',
|
|
26
|
+
new OIDCStrategy(
|
|
27
|
+
{
|
|
28
|
+
name: 'acme',
|
|
29
|
+
issuer: 'https://login.acme.example',
|
|
30
|
+
clientId: (env) => env.ACME_CLIENT_ID,
|
|
31
|
+
clientSecret: (env) => env.ACME_CLIENT_SECRET,
|
|
32
|
+
},
|
|
33
|
+
// verify: the provider's profile → your user (null refuses the login)
|
|
34
|
+
async ({ profile }) =>
|
|
35
|
+
(await User.objects.filter({ email: profile.email }).first()) ??
|
|
36
|
+
User.objects.createUser(profile.username ?? profile.id, profile.email),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
strategies.use(
|
|
41
|
+
'email',
|
|
42
|
+
new MagicLinkStrategy(
|
|
43
|
+
{ send: async ({ email, url }) => sendMail(email, 'Your login link', url) },
|
|
44
|
+
async ({ email }) => User.objects.filter({ email }).first(),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/accounts/urls.ts
|
|
51
|
+
import { path } from '@ghuri/core'
|
|
52
|
+
import { authenticate } from '@ghuri/oauth'
|
|
53
|
+
import './strategies'
|
|
54
|
+
|
|
55
|
+
export const urlpatterns = [
|
|
56
|
+
// one URL for both legs: without ?code it redirects to the provider, with it it logs in
|
|
57
|
+
path('login/acme/', authenticate('acme', { failureRedirect: '/login/' })),
|
|
58
|
+
path('login/email/', authenticate('email', { successRedirect: '/' })),
|
|
59
|
+
]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Strategies
|
|
63
|
+
|
|
64
|
+
A strategy's `authenticate(c, options)` returns one of:
|
|
65
|
+
|
|
66
|
+
| Result | Meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `{ type: 'success', user, info? }` | Log this user in. |
|
|
69
|
+
| `{ type: 'fail', status?, message?, challenge? }` | Refuse (`challenge` becomes `WWW-Authenticate`). |
|
|
70
|
+
| `{ type: 'redirect', url }` | Send the browser somewhere (the provider). |
|
|
71
|
+
| `{ type: 'pass' }` | Not for me: try the next strategy. |
|
|
72
|
+
| `{ type: 'error', error }` | Something broke (becomes a 500). |
|
|
73
|
+
| `{ type: 'response', response }` | The strategy answered itself ("we sent you a link"). |
|
|
74
|
+
|
|
75
|
+
| Built-in | |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `OAuth2Strategy` | Authorization code flow with PKCE (S256) and state. `authorizationURL`, `tokenURL`, `clientId`, `clientSecret`, `scope`, `userInfoURL` or `profile(tokens, ctx)`, `tokenAuth: 'body' \| 'basic'`, `authorizationParams`, `callbackURL`. |
|
|
78
|
+
| `OIDCStrategy` | OAuth2 + discovery (`issuer`), `nonce`, ID token validation against the issuer's JWKS (RS*, PS*, ES*, EdDSA; `iss`, `aud`/`azp`, `exp`, `nbf`, `iat`, `nonce`), userinfo. `issuerValidation` for multi-tenant issuers. |
|
|
79
|
+
| `LocalStrategy` | Username/password from a JSON or form POST. Default `verify` is `@ghuri/auth`'s `authenticate()`. |
|
|
80
|
+
| `MagicLinkStrategy` | `POST { email }` sends a link (same answer for unknown addresses); `GET ?token=` logs in. Links expire (`maxAge`, default 15 minutes) and stop working after one login. |
|
|
81
|
+
| `BearerStrategy` | `Authorization: Bearer <token>` → `verify(token)`. For API keys. |
|
|
82
|
+
| `CustomStrategy` | `new CustomStrategy('name', (c, options) => result)`. |
|
|
83
|
+
|
|
84
|
+
Settings values can be functions of the env: `clientId: (env) => env.ACME_CLIENT_ID`.
|
|
85
|
+
|
|
86
|
+
Profiles are normalized: `{ provider, id, email?, emailVerified?, username?, name?, firstName?, lastName?, avatarUrl?, raw }`.
|
|
87
|
+
|
|
88
|
+
## `authenticate(names, options)`
|
|
89
|
+
|
|
90
|
+
Turns strategies into a view. Several names are tried in order until one does not pass.
|
|
91
|
+
|
|
92
|
+
| Option | |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `successRedirect` / `failureRedirect` | Where to go (failures add `?error=<message>`). |
|
|
95
|
+
| `successReturnToOrRedirect` | The safe `next` kept across the round trip, else this. |
|
|
96
|
+
| `respond` | `'session'` (default: `@ghuri/auth` `login()`), `'jwt'` (JSON `{ access, refresh, user }`), or `(c, user, info) => Response`. |
|
|
97
|
+
| `session` | `false` to skip the session login. |
|
|
98
|
+
| `next` | Override the `next` URL (default: the `?next=` query parameter). |
|
|
99
|
+
| `scope`, `state` | Extra scopes; extra values kept across the round trip (`info.state`). |
|
|
100
|
+
|
|
101
|
+
Without redirects configured, JSON clients get `{ user }` (or `{ detail }` with 401) and browsers go to a safe `next`
|
|
102
|
+
or `LOGIN_REDIRECT_URL`. `runStrategy(c, names)` runs strategies inside your own view.
|
|
103
|
+
|
|
104
|
+
## Round-trip state
|
|
105
|
+
|
|
106
|
+
`state`, the PKCE verifier, the OIDC `nonce` and `next` live in an encrypted (AES-GCM, key derived from `SECRET_KEY`
|
|
107
|
+
with HKDF), HttpOnly, `SameSite=Lax`, 10-minute cookie, so flows work without sessions. Up to five pending flows
|
|
108
|
+
coexist (several tabs). A missing, expired or reused state fails with 400; the provider's `error=access_denied`
|
|
109
|
+
fails with "You cancelled the login."
|
|
110
|
+
|
|
111
|
+
## REST
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { StrategyAuthentication } from '@ghuri/oauth/rest'
|
|
115
|
+
|
|
116
|
+
strategies.use('api-key', new BearerStrategy(async (token) => ApiKey.userFor(token)))
|
|
117
|
+
|
|
118
|
+
class Reports extends APIView {
|
|
119
|
+
authenticationClasses = [StrategyAuthentication(['api-key'])]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Lower-level pieces
|
|
124
|
+
|
|
125
|
+
`OAuth2Client` (`authorizationUrl`, `exchangeCode`, `refresh`, `revoke`), `discover(issuer)`, `verifyJwt(token, opts)`,
|
|
126
|
+
`signJwt(payload, key, alg)`, `importPkcs8(pem, alg)`, `seal` / `unseal`, `pkcePair()`.
|
|
127
|
+
|
|
128
|
+
## Settings
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
OAUTH: {
|
|
132
|
+
STATE_COOKIE: 'ghuri_oauth',
|
|
133
|
+
STATE_MAX_AGE: 600,
|
|
134
|
+
HTTP_TIMEOUT: 10_000,
|
|
135
|
+
CALLBACK_BASE_URL: undefined, // default: the request's origin; set it behind proxies that rewrite the host
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Testing
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { followOAuth, mockOAuthProvider } from '@ghuri/oauth/testing'
|
|
143
|
+
|
|
144
|
+
const provider = mockOAuthProvider({ issuer: 'https://login.acme.example', profile: { sub: '42', email: 'ann@example.com', email_verified: true } })
|
|
145
|
+
const res = await followOAuth(client, '/login/acme/', provider) // redirect → approve → callback
|
|
146
|
+
provider.restore()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The mock intercepts provider requests (token, userinfo, discovery, JWKS, plus `routes` you add), checks PKCE and the
|
|
150
|
+
redirect URI, and signs ID tokens with a fresh ES256 key.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/strategy.ts
|
|
2
|
+
import { ImproperlyConfigured } from "@ghuri/core";
|
|
3
|
+
var Strategy = class {
|
|
4
|
+
success(user, info) {
|
|
5
|
+
return { type: "success", user, info };
|
|
6
|
+
}
|
|
7
|
+
fail(message, status = 401, challenge) {
|
|
8
|
+
return { type: "fail", message, status, challenge };
|
|
9
|
+
}
|
|
10
|
+
redirect(url, status = 302) {
|
|
11
|
+
return { type: "redirect", url, status };
|
|
12
|
+
}
|
|
13
|
+
pass() {
|
|
14
|
+
return { type: "pass" };
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var CustomStrategy = class extends Strategy {
|
|
18
|
+
constructor(name, fn) {
|
|
19
|
+
super();
|
|
20
|
+
this.name = name;
|
|
21
|
+
this.fn = fn;
|
|
22
|
+
}
|
|
23
|
+
name;
|
|
24
|
+
fn;
|
|
25
|
+
async authenticate(c, options) {
|
|
26
|
+
return this.fn(c, options);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var KEY = /* @__PURE__ */ Symbol.for("ghuri.oauth.strategies");
|
|
30
|
+
var registry = (() => {
|
|
31
|
+
const g = globalThis;
|
|
32
|
+
if (!g[KEY]) g[KEY] = /* @__PURE__ */ new Map();
|
|
33
|
+
return g[KEY];
|
|
34
|
+
})();
|
|
35
|
+
var strategies = {
|
|
36
|
+
use(name, strategy) {
|
|
37
|
+
registry.set(name, strategy);
|
|
38
|
+
},
|
|
39
|
+
get(name) {
|
|
40
|
+
const s = registry.get(name);
|
|
41
|
+
if (!s) {
|
|
42
|
+
throw new ImproperlyConfigured(
|
|
43
|
+
`Unknown authentication strategy '${name}'. Register it with strategies.use('${name}', \u2026).`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return s;
|
|
47
|
+
},
|
|
48
|
+
has(name) {
|
|
49
|
+
return registry.has(name);
|
|
50
|
+
},
|
|
51
|
+
unuse(name) {
|
|
52
|
+
return registry.delete(name);
|
|
53
|
+
},
|
|
54
|
+
names() {
|
|
55
|
+
return [...registry.keys()];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function resolveStrategy(ref) {
|
|
59
|
+
return typeof ref === "string" ? strategies.get(ref) : ref;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
Strategy,
|
|
64
|
+
CustomStrategy,
|
|
65
|
+
strategies,
|
|
66
|
+
resolveStrategy
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=chunk-IS4XX7EU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/strategy.ts"],"sourcesContent":["import { ImproperlyConfigured } from '@ghuri/core'\nimport type { Context } from 'hono'\nimport type { AuthenticateOptions, StrategyResult } from './types'\n\n/**\n * A way to authenticate a request (Passport.js's `Strategy`). `authenticate()` returns what happened:\n * success, fail, redirect, pass (not for me), error, or a response of its own.\n */\nexport abstract class Strategy {\n abstract readonly name: string\n abstract authenticate(c: Context, options: AuthenticateOptions): Promise<StrategyResult>\n\n protected success(user: unknown, info?: Record<string, unknown>): StrategyResult {\n return { type: 'success', user, info }\n }\n protected fail(message?: string, status = 401, challenge?: string): StrategyResult {\n return { type: 'fail', message, status, challenge }\n }\n protected redirect(url: string, status = 302): StrategyResult {\n return { type: 'redirect', url, status }\n }\n protected pass(): StrategyResult {\n return { type: 'pass' }\n }\n}\n\n/** A one-off strategy from a function: `new CustomStrategy('header', async (c) => …)`. */\nexport class CustomStrategy extends Strategy {\n constructor(\n readonly name: string,\n private readonly fn: (\n c: Context,\n options: AuthenticateOptions,\n ) => Promise<StrategyResult> | StrategyResult,\n ) {\n super()\n }\n async authenticate(c: Context, options: AuthenticateOptions): Promise<StrategyResult> {\n return this.fn(c, options)\n }\n}\n\nconst KEY = Symbol.for('ghuri.oauth.strategies')\nconst registry: Map<string, Strategy> = (() => {\n const g = globalThis as any\n if (!g[KEY]) g[KEY] = new Map<string, Strategy>()\n return g[KEY]\n})()\n\n/** The strategy registry (Passport's `passport.use()`). */\nexport const strategies = {\n use(name: string, strategy: Strategy): void {\n registry.set(name, strategy)\n },\n get(name: string): Strategy {\n const s = registry.get(name)\n if (!s) {\n throw new ImproperlyConfigured(\n `Unknown authentication strategy '${name}'. Register it with strategies.use('${name}', …).`,\n )\n }\n return s\n },\n has(name: string): boolean {\n return registry.has(name)\n },\n unuse(name: string): boolean {\n return registry.delete(name)\n },\n names(): string[] {\n return [...registry.keys()]\n },\n}\n\n/** A strategy instance or a registered name. */\nexport type StrategyRef = string | Strategy\n\nexport function resolveStrategy(ref: StrategyRef): Strategy {\n return typeof ref === 'string' ? strategies.get(ref) : ref\n}\n"],"mappings":";AAAA,SAAS,4BAA4B;AAQ9B,IAAe,WAAf,MAAwB;AAAA,EAInB,QAAQ,MAAe,MAAgD;AAC/E,WAAO,EAAE,MAAM,WAAW,MAAM,KAAK;AAAA,EACvC;AAAA,EACU,KAAK,SAAkB,SAAS,KAAK,WAAoC;AACjF,WAAO,EAAE,MAAM,QAAQ,SAAS,QAAQ,UAAU;AAAA,EACpD;AAAA,EACU,SAAS,KAAa,SAAS,KAAqB;AAC5D,WAAO,EAAE,MAAM,YAAY,KAAK,OAAO;AAAA,EACzC;AAAA,EACU,OAAuB;AAC/B,WAAO,EAAE,MAAM,OAAO;AAAA,EACxB;AACF;AAGO,IAAM,iBAAN,cAA6B,SAAS;AAAA,EAC3C,YACW,MACQ,IAIjB;AACA,UAAM;AANG;AACQ;AAAA,EAMnB;AAAA,EAPW;AAAA,EACQ;AAAA,EAOnB,MAAM,aAAa,GAAY,SAAuD;AACpF,WAAO,KAAK,GAAG,GAAG,OAAO;AAAA,EAC3B;AACF;AAEA,IAAM,MAAM,uBAAO,IAAI,wBAAwB;AAC/C,IAAM,YAAmC,MAAM;AAC7C,QAAM,IAAI;AACV,MAAI,CAAC,EAAE,GAAG,EAAG,GAAE,GAAG,IAAI,oBAAI,IAAsB;AAChD,SAAO,EAAE,GAAG;AACd,GAAG;AAGI,IAAM,aAAa;AAAA,EACxB,IAAI,MAAc,UAA0B;AAC1C,aAAS,IAAI,MAAM,QAAQ;AAAA,EAC7B;AAAA,EACA,IAAI,MAAwB;AAC1B,UAAM,IAAI,SAAS,IAAI,IAAI;AAC3B,QAAI,CAAC,GAAG;AACN,YAAM,IAAI;AAAA,QACR,oCAAoC,IAAI,uCAAuC,IAAI;AAAA,MACrF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EACA,IAAI,MAAuB;AACzB,WAAO,SAAS,IAAI,IAAI;AAAA,EAC1B;AAAA,EACA,MAAM,MAAuB;AAC3B,WAAO,SAAS,OAAO,IAAI;AAAA,EAC7B;AAAA,EACA,QAAkB;AAChB,WAAO,CAAC,GAAG,SAAS,KAAK,CAAC;AAAA,EAC5B;AACF;AAKO,SAAS,gBAAgB,KAA4B;AAC1D,SAAO,OAAO,QAAQ,WAAW,WAAW,IAAI,GAAG,IAAI;AACzD;","names":[]}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveStrategy
|
|
3
|
+
} from "./chunk-IS4XX7EU.js";
|
|
4
|
+
|
|
5
|
+
// src/authenticate.ts
|
|
6
|
+
import { getBackends, isSafeRedirect, login, tokenPairForUser } from "@ghuri/auth";
|
|
7
|
+
import {
|
|
8
|
+
getHost,
|
|
9
|
+
ImproperlyConfigured,
|
|
10
|
+
resolveUrl,
|
|
11
|
+
tryGetContext,
|
|
12
|
+
wantsJson
|
|
13
|
+
} from "@ghuri/core";
|
|
14
|
+
async function runStrategy(c, refs, options = {}) {
|
|
15
|
+
const list = Array.isArray(refs) ? refs : [refs];
|
|
16
|
+
for (const ref of list) {
|
|
17
|
+
const strategy = resolveStrategy(ref);
|
|
18
|
+
let result;
|
|
19
|
+
try {
|
|
20
|
+
result = await strategy.authenticate(c, options);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
result = { type: "error", error };
|
|
23
|
+
}
|
|
24
|
+
if (result.type !== "pass") return { ...result, strategy: strategy.name };
|
|
25
|
+
}
|
|
26
|
+
return { type: "fail", status: 401, message: "Authentication credentials were not provided." };
|
|
27
|
+
}
|
|
28
|
+
function loginBackendName() {
|
|
29
|
+
const backend = getBackends().find((b) => typeof b.getUser === "function");
|
|
30
|
+
if (!backend) {
|
|
31
|
+
throw new ImproperlyConfigured(
|
|
32
|
+
"AUTHENTICATION_BACKENDS has no backend with getUser(); strategies cannot log users in."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return backend.backendName;
|
|
36
|
+
}
|
|
37
|
+
function userInfo(user) {
|
|
38
|
+
const M = user?.constructor;
|
|
39
|
+
const out = { pk: user.pk };
|
|
40
|
+
const uf = M?.USERNAME_FIELD ?? "username";
|
|
41
|
+
if (user[uf] !== void 0) out[uf] = user[uf];
|
|
42
|
+
const ef = M?.EMAIL_FIELD ?? "email";
|
|
43
|
+
if (user[ef] !== void 0 && ef !== uf) out[ef] = user[ef];
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function loginRedirectUrl() {
|
|
47
|
+
return resolveUrl(
|
|
48
|
+
tryGetContext()?.settings.LOGIN_REDIRECT_URL ?? "/accounts/profile/"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
async function completeLogin(c, user, info, options) {
|
|
52
|
+
const respond = options.respond ?? "session";
|
|
53
|
+
const useSession = options.session ?? respond !== "jwt";
|
|
54
|
+
if (useSession) await login(c, user, loginBackendName());
|
|
55
|
+
if (typeof respond === "function") return respond(c, user, info);
|
|
56
|
+
if (respond === "jwt") return c.json({ ...await tokenPairForUser(user), user: userInfo(user) });
|
|
57
|
+
const next = info?.next ?? c.req.query("next");
|
|
58
|
+
const safeNext = isSafeRedirect(next, [getHost(c)]) ? next : void 0;
|
|
59
|
+
let target;
|
|
60
|
+
if (options.successReturnToOrRedirect !== void 0) target = safeNext ?? options.successReturnToOrRedirect;
|
|
61
|
+
else if (options.successRedirect !== void 0) target = options.successRedirect;
|
|
62
|
+
else target = safeNext;
|
|
63
|
+
if (!target && wantsJson(c)) return c.json({ user: userInfo(user) });
|
|
64
|
+
return c.redirect(target ?? loginRedirectUrl(), 302);
|
|
65
|
+
}
|
|
66
|
+
function failureResponse(c, result, options) {
|
|
67
|
+
const message = result.message ?? "Authentication failed.";
|
|
68
|
+
if (options.failureRedirect && !wantsJson(c)) {
|
|
69
|
+
const url = new URL(options.failureRedirect, "http://x");
|
|
70
|
+
url.searchParams.set("error", message);
|
|
71
|
+
return c.redirect(`${options.failureRedirect.split("?")[0]}${url.search}`, 302);
|
|
72
|
+
}
|
|
73
|
+
const headers = {};
|
|
74
|
+
if (result.challenge) headers["WWW-Authenticate"] = result.challenge;
|
|
75
|
+
const status = result.status ?? 401;
|
|
76
|
+
if (wantsJson(c) || !options.failureRedirect) return c.json({ detail: message }, status, headers);
|
|
77
|
+
return c.text(message, status, headers);
|
|
78
|
+
}
|
|
79
|
+
function authenticate(refs, options = {}) {
|
|
80
|
+
const view = async (c) => {
|
|
81
|
+
const result = await runStrategy(c, refs, options);
|
|
82
|
+
switch (result.type) {
|
|
83
|
+
case "redirect":
|
|
84
|
+
return c.redirect(result.url, result.status ?? 302);
|
|
85
|
+
case "response":
|
|
86
|
+
return result.response;
|
|
87
|
+
case "error":
|
|
88
|
+
throw result.error;
|
|
89
|
+
case "fail":
|
|
90
|
+
return failureResponse(c, result, options);
|
|
91
|
+
case "success": {
|
|
92
|
+
if (result.user?.isActive === false) {
|
|
93
|
+
return failureResponse(c, { status: 401, message: "This account is inactive." }, options);
|
|
94
|
+
}
|
|
95
|
+
return completeLogin(c, result.user, result.info, options);
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return failureResponse(c, { status: 401 }, options);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return view;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
runStrategy,
|
|
106
|
+
loginBackendName,
|
|
107
|
+
userInfo,
|
|
108
|
+
completeLogin,
|
|
109
|
+
failureResponse,
|
|
110
|
+
authenticate
|
|
111
|
+
};
|
|
112
|
+
//# sourceMappingURL=chunk-OIBPPYBX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/authenticate.ts"],"sourcesContent":["// `authenticate(names, options)`: turn strategies into a view (Passport's `passport.authenticate()`).\nimport { getBackends, isSafeRedirect, login, tokenPairForUser } from '@ghuri/auth'\nimport {\n getHost,\n ImproperlyConfigured,\n resolveUrl,\n tryGetContext,\n type ViewFunction,\n wantsJson,\n} from '@ghuri/core'\nimport type { Context } from 'hono'\nimport { resolveStrategy, type StrategyRef } from './strategy'\nimport type { AuthenticateOptions, StrategyResult, StrategyUser } from './types'\n\nexport type NamedResult = StrategyResult & { strategy?: string }\n\n/** Run strategies in order until one does not `pass`. All pass → fail 401. Thrown errors → `error`. */\nexport async function runStrategy(\n c: Context,\n refs: StrategyRef | StrategyRef[],\n options: AuthenticateOptions = {},\n): Promise<NamedResult> {\n const list = Array.isArray(refs) ? refs : [refs]\n for (const ref of list) {\n const strategy = resolveStrategy(ref)\n let result: StrategyResult\n try {\n result = await strategy.authenticate(c, options)\n } catch (error) {\n result = { type: 'error', error }\n }\n if (result.type !== 'pass') return { ...result, strategy: strategy.name }\n }\n return { type: 'fail', status: 401, message: 'Authentication credentials were not provided.' }\n}\n\n/** The name `login()` stores in the session: the first configured backend that can load users. */\nexport function loginBackendName(): string {\n const backend = getBackends().find((b) => typeof b.getUser === 'function')\n if (!backend) {\n throw new ImproperlyConfigured(\n 'AUTHENTICATION_BACKENDS has no backend with getUser(); strategies cannot log users in.',\n )\n }\n return backend.backendName\n}\n\nexport function userInfo(user: any): Record<string, unknown> {\n const M = user?.constructor as { USERNAME_FIELD?: string; EMAIL_FIELD?: string } | undefined\n const out: Record<string, unknown> = { pk: user.pk }\n const uf = M?.USERNAME_FIELD ?? 'username'\n if (user[uf] !== undefined) out[uf] = user[uf]\n const ef = M?.EMAIL_FIELD ?? 'email'\n if (user[ef] !== undefined && ef !== uf) out[ef] = user[ef]\n return out\n}\n\nfunction loginRedirectUrl(): string {\n return resolveUrl(\n (tryGetContext()?.settings.LOGIN_REDIRECT_URL as string | undefined) ?? '/accounts/profile/',\n )\n}\n\n/** Log `user` in and answer as `options` say. Exposed for packages that run strategies themselves. */\nexport async function completeLogin(\n c: Context,\n user: StrategyUser,\n info: Record<string, unknown> | undefined,\n options: AuthenticateOptions,\n): Promise<Response> {\n const respond = options.respond ?? 'session'\n const useSession = options.session ?? respond !== 'jwt'\n if (useSession) await login(c, user, loginBackendName())\n if (typeof respond === 'function') return respond(c, user, info)\n if (respond === 'jwt') return c.json({ ...(await tokenPairForUser(user)), user: userInfo(user) })\n\n const next = (info?.next as string | undefined) ?? c.req.query('next')\n const safeNext = isSafeRedirect(next, [getHost(c)]) ? next : undefined\n let target: string | undefined\n if (options.successReturnToOrRedirect !== undefined) target = safeNext ?? options.successReturnToOrRedirect\n else if (options.successRedirect !== undefined) target = options.successRedirect\n else target = safeNext\n if (!target && wantsJson(c)) return c.json({ user: userInfo(user) })\n return c.redirect(target ?? loginRedirectUrl(), 302)\n}\n\nexport function failureResponse(\n c: Context,\n result: { status?: number; challenge?: string; message?: string },\n options: AuthenticateOptions,\n): Response {\n const message = result.message ?? 'Authentication failed.'\n if (options.failureRedirect && !wantsJson(c)) {\n const url = new URL(options.failureRedirect, 'http://x')\n url.searchParams.set('error', message)\n return c.redirect(`${options.failureRedirect.split('?')[0]}${url.search}`, 302)\n }\n const headers: Record<string, string> = {}\n if (result.challenge) headers['WWW-Authenticate'] = result.challenge\n const status = (result.status ?? 401) as 400 | 401 | 403\n if (wantsJson(c) || !options.failureRedirect) return c.json({ detail: message }, status, headers)\n return c.text(message, status, headers)\n}\n\n/**\n * A view that runs the strategies: `path('login/github/', authenticate('github', { successRedirect: '/' }))`.\n * Several names are tried in order (the first that does not pass wins).\n */\nexport function authenticate(\n refs: StrategyRef | StrategyRef[],\n options: AuthenticateOptions = {},\n): ViewFunction {\n const view: ViewFunction = async (c) => {\n const result = await runStrategy(c, refs, options)\n switch (result.type) {\n case 'redirect':\n return c.redirect(result.url, (result.status ?? 302) as 302)\n case 'response':\n return result.response\n case 'error':\n throw result.error\n case 'fail':\n return failureResponse(c, result, options)\n case 'success': {\n if (result.user?.isActive === false) {\n return failureResponse(c, { status: 401, message: 'This account is inactive.' }, options)\n }\n return completeLogin(c, result.user, result.info, options)\n }\n default:\n return failureResponse(c, { status: 401 }, options)\n }\n }\n return view\n}\n"],"mappings":"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","names":[]}
|