@nixxie-cms/auth-oauth 1.0.1 → 1.1.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
@@ -33,3 +33,40 @@ app.get('/auth/google/callback', async (req, res) => {
33
33
  // upsert a user keyed by profile.id / profile.email and start a session
34
34
  })
35
35
  ```
36
+
37
+ ## Automatic routes
38
+
39
+ Skip the hand-written routes entirely: `installOAuthRoutes` mounts
40
+ `GET /auth/:provider` (redirect to consent screen) and `GET /auth/:provider/callback`
41
+ (code exchange, find-or-create user, session start) for every configured provider, with
42
+ signed-cookie state validation built in.
43
+
44
+ ```ts
45
+ import { installOAuthRoutes } from '@nixxie-cms/auth-oauth'
46
+
47
+ server: {
48
+ extendExpressApp: (app, context) => {
49
+ installOAuthRoutes(app, context, { oauth, listKey: 'User' })
50
+ },
51
+ }
52
+ ```
53
+
54
+ Or as a plugin:
55
+
56
+ ```ts
57
+ import { oauthPlugin } from '@nixxie-cms/auth-oauth'
58
+
59
+ config({
60
+ plugins: [oauthPlugin({ oauth, listKey: 'User' })],
61
+ })
62
+ ```
63
+
64
+ Users are matched by the profile's email against `identityField` (default `'email'`) and
65
+ created on first sign-in unless `allowSignup: false`. Sessions are started via the
66
+ configured session strategy with `{ itemId }`, compatible with `createAuth` sessions.
67
+
68
+ Options: `listKey` (default `'User'`), `identityField`, `allowSignup`, `resolveUser`
69
+ (data for users created on first sign-in — return `null` to reject), `onSignIn`
70
+ (post-sign-in hook with `{ provider, itemId, created, profile, tokens, context }`),
71
+ `basePath` (default `'/auth'`), `successRedirect` (default `'/'`) and `failureRedirect`
72
+ (default `'/signin'`, gets `?error=<code>` appended).
@@ -0,0 +1,82 @@
1
+ import type { NixxieContext, NixxiePlugin } from '@nixxie-cms/core/types';
2
+ import type { OAuth } from "./index.js";
3
+ import type { OAuthProfile, OAuthTokens } from "./types.js";
4
+ export type OAuthRoutesOptions = {
5
+ /** The OAuth client created with `createOAuth()`. */
6
+ oauth: OAuth;
7
+ /**
8
+ * Collection holding your users.
9
+ * @default 'User'
10
+ */
11
+ listKey?: string;
12
+ /**
13
+ * Field used to match OAuth profiles to existing users (by the profile's email).
14
+ * @default 'email'
15
+ */
16
+ identityField?: string;
17
+ /**
18
+ * Create users on first sign-in. When false, sign-ins from unknown
19
+ * profiles are redirected to `failureRedirect`.
20
+ * @default true
21
+ */
22
+ allowSignup?: boolean;
23
+ /**
24
+ * Resolve the data for a user being created on first sign-in.
25
+ * Return null to reject the sign-in. Defaults to `{ [identityField]: profile.email }`
26
+ * plus `name` when the collection's input accepts it.
27
+ */
28
+ resolveUser?: (args: {
29
+ provider: string;
30
+ profile: OAuthProfile;
31
+ tokens: OAuthTokens;
32
+ context: NixxieContext;
33
+ }) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null;
34
+ /**
35
+ * Called after a successful sign-in (e.g. to link accounts or audit). Receives the
36
+ * signed-in user's id.
37
+ */
38
+ onSignIn?: (args: {
39
+ provider: string;
40
+ itemId: string;
41
+ created: boolean;
42
+ profile: OAuthProfile;
43
+ tokens: OAuthTokens;
44
+ context: NixxieContext;
45
+ }) => Promise<void> | void;
46
+ /**
47
+ * Base path the routes are mounted under: `<basePath>/:provider` starts the flow and
48
+ * `<basePath>/:provider/callback` completes it.
49
+ * @default '/auth'
50
+ */
51
+ basePath?: string;
52
+ /** Where to send the user after a successful sign-in. @default '/' */
53
+ successRedirect?: string;
54
+ /** Where to send the user after a failure (gets `?error=<code>` appended). @default '/signin' */
55
+ failureRedirect?: string;
56
+ };
57
+ /**
58
+ * Mount ready-made OAuth sign-in routes on the Express app:
59
+ *
60
+ * - `GET <basePath>/:provider` — redirects to the provider's consent screen
61
+ * - `GET <basePath>/:provider/callback` — exchanges the code, finds-or-creates the
62
+ * user, starts a session (compatible with `createAuth` sessions: `{ itemId }`),
63
+ * and redirects
64
+ *
65
+ * Call from `server.extendExpressApp`, or use `oauthPlugin()` to wire it automatically.
66
+ */
67
+ export declare function installOAuthRoutes(app: any, context: NixxieContext, options: OAuthRoutesOptions): void;
68
+ /**
69
+ * The same OAuth routes as `installOAuthRoutes`, packaged as a Nixxie plugin.
70
+ *
71
+ * @example
72
+ * config({
73
+ * plugins: [
74
+ * oauthPlugin({
75
+ * oauth: createOAuth({ providers: { google: { provider: 'google', ... } } }),
76
+ * listKey: 'User',
77
+ * }),
78
+ * ],
79
+ * })
80
+ */
81
+ export declare function oauthPlugin(options: OAuthRoutesOptions): NixxiePlugin;
82
+ //# sourceMappingURL=express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.d.ts","sourceRoot":"../../../src","sources":["express.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AACzE,OAAO,KAAK,EAAE,KAAK,EAAE,mBAAe;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,mBAAe;AAIxD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,qDAAqD;IACrD,KAAK,EAAE,KAAK,CAAA;IACZ;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE;QACnB,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,EAAE,YAAY,CAAA;QACrB,MAAM,EAAE,WAAW,CAAA;QACnB,OAAO,EAAE,aAAa,CAAA;KACvB,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC9E;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE;QAChB,QAAQ,EAAE,MAAM,CAAA;QAChB,MAAM,EAAE,MAAM,CAAA;QACd,OAAO,EAAE,OAAO,CAAA;QAChB,OAAO,EAAE,YAAY,CAAA;QACrB,MAAM,EAAE,WAAW,CAAA;QACnB,OAAO,EAAE,aAAa,CAAA;KACvB,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iGAAiG;IACjG,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAaD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,aAAa,EACtB,OAAO,EAAE,kBAAkB,GAC1B,IAAI,CAwGN;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,YAAY,CAcrE"}
@@ -24,6 +24,8 @@ export declare class OAuth {
24
24
  }>;
25
25
  }
26
26
  export declare function createOAuth(config: OAuthConfig): OAuth;
27
+ export { installOAuthRoutes, oauthPlugin } from "./express.js";
28
+ export type { OAuthRoutesOptions } from "./express.js";
27
29
  export { builtInProviders as providers };
28
30
  export type { OAuthConfig, OAuthProviderConfig, OAuthProviderDefinition, OAuthProviderName, OAuthProfile, OAuthTokens, } from "./types.js";
29
31
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,IAAI,gBAAgB,EAAE,uBAAmB;AAC3D,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EAGZ,WAAW,EACZ,mBAAe;AAQhB,qBAAa,KAAK;IAChB,OAAO,CAAC,MAAM,CAAa;gBAEf,MAAM,EAAE,WAAW;IAI/B,yCAAyC;IACzC,IAAI,IAAI,MAAM,EAAE;IAIhB,OAAO,CAAC,GAAG;IAMX,0FAA0F;IAC1F,WAAW,IAAI,MAAM;IAIrB,wEAAwE;IACxE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,MAAM;IAa1G,8EAA8E;IACxE,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IA+BpE,+DAA+D;IACzD,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IAc5E,wEAAwE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE,CAAC;CAKpG;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,KAAK,CAEtD;AAED,OAAO,EAAE,gBAAgB,IAAI,SAAS,EAAE,CAAA;AACxC,YAAY,EACV,WAAW,EACX,mBAAmB,EACnB,uBAAuB,EACvB,iBAAiB,EACjB,YAAY,EACZ,WAAW,GACZ,mBAAe"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,IAAI,gBAAgB,EAAE,uBAAmB;AAC3D,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EAGZ,WAAW,EACZ,mBAAe;AAQhB,qBAAa,KAAK;IAChB,OAAO,CAAC,MAAM,CAAa;gBAEf,MAAM,EAAE,WAAW;IAI/B,yCAAyC;IACzC,IAAI,IAAI,MAAM,EAAE;IAIhB,OAAO,CAAC,GAAG;IAMX,0FAA0F;IAC1F,WAAW,IAAI,MAAM;IAIrB,wEAAwE;IACxE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,MAAM;IAa1G,8EAA8E;IACxE,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IA+BpE,+DAA+D;IACzD,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IAc5E,wEAAwE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE,CAAC;CAKpG;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,KAAK,CAEtD;AAED,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,qBAAiB;AAC3D,YAAY,EAAE,kBAAkB,EAAE,qBAAiB;AACnD,OAAO,EAAE,gBAAgB,IAAI,SAAS,EAAE,CAAA;AACxC,YAAY,EACV,WAAW,EACX,mBAAmB,EACnB,uBAAuB,EACvB,iBAAiB,EACjB,YAAY,EACZ,WAAW,GACZ,mBAAe"}
@@ -97,6 +97,184 @@ const providers = {
97
97
  }
98
98
  };
99
99
 
100
+ const STATE_COOKIE = 'nixxie-oauth-state';
101
+ function parseCookies(header) {
102
+ const out = {};
103
+ if (!header) return out;
104
+ for (const part of header.split(';')) {
105
+ const eq = part.indexOf('=');
106
+ if (eq === -1) continue;
107
+ out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Mount ready-made OAuth sign-in routes on the Express app:
114
+ *
115
+ * - `GET <basePath>/:provider` — redirects to the provider's consent screen
116
+ * - `GET <basePath>/:provider/callback` — exchanges the code, finds-or-creates the
117
+ * user, starts a session (compatible with `createAuth` sessions: `{ itemId }`),
118
+ * and redirects
119
+ *
120
+ * Call from `server.extendExpressApp`, or use `oauthPlugin()` to wire it automatically.
121
+ */
122
+ function installOAuthRoutes(app, context, options) {
123
+ const {
124
+ oauth,
125
+ listKey = 'User',
126
+ identityField = 'email',
127
+ allowSignup = true,
128
+ resolveUser,
129
+ onSignIn,
130
+ basePath = '/auth',
131
+ successRedirect = '/',
132
+ failureRedirect = '/signin'
133
+ } = options;
134
+
135
+ // The state value is signed so the callback can verify it even though the cookie
136
+ // itself is the only storage — no server-side flow state needed.
137
+ const stateSecret = node_crypto.randomBytes(32).toString('hex');
138
+ const signState = value => `${value}.${node_crypto.createHmac('sha256', stateSecret).update(value).digest('hex')}`;
139
+ const verifyState = signed => {
140
+ if (!signed) return;
141
+ const at = signed.lastIndexOf('.');
142
+ if (at === -1) return;
143
+ const value = signed.slice(0, at);
144
+ const mac = node_crypto.createHmac('sha256', stateSecret).update(value).digest('hex');
145
+ return mac === signed.slice(at + 1) ? value : undefined;
146
+ };
147
+ const fail = (res, code) => {
148
+ const sep = failureRedirect.includes('?') ? '&' : '?';
149
+ res.redirect(`${failureRedirect}${sep}error=${encodeURIComponent(code)}`);
150
+ };
151
+ app.get(`${basePath}/:provider`, (req, res) => {
152
+ try {
153
+ var _res$cookie;
154
+ const provider = req.params.provider;
155
+ if (!oauth.list().includes(provider)) return fail(res, 'unknown-provider');
156
+ const state = oauth.createState();
157
+ (_res$cookie = res.cookie) === null || _res$cookie === void 0 || _res$cookie.call(res, STATE_COOKIE, signState(`${provider}:${state}`), {
158
+ httpOnly: true,
159
+ sameSite: 'lax',
160
+ secure: process.env.NODE_ENV === 'production',
161
+ maxAge: 10 * 60 * 1000,
162
+ path: basePath
163
+ });
164
+ res.redirect(oauth.authorizationUrl(provider, {
165
+ state
166
+ }));
167
+ } catch (err) {
168
+ console.error('[nixxie/auth-oauth] start failed:', err);
169
+ fail(res, 'oauth-start-failed');
170
+ }
171
+ });
172
+ app.get(`${basePath}/:provider/callback`, (req, res) => {
173
+ void (async (_req$query, _res$clearCookie) => {
174
+ const provider = req.params.provider;
175
+ const {
176
+ code,
177
+ state,
178
+ error
179
+ } = (_req$query = req.query) !== null && _req$query !== void 0 ? _req$query : {};
180
+ if (error) return fail(res, String(error));
181
+ if (!code) return fail(res, 'missing-code');
182
+ const stored = verifyState(parseCookies(req.headers.cookie)[STATE_COOKIE]);
183
+ (_res$clearCookie = res.clearCookie) === null || _res$clearCookie === void 0 || _res$clearCookie.call(res, STATE_COOKIE, {
184
+ path: basePath
185
+ });
186
+ if (!stored || stored !== `${provider}:${state}`) return fail(res, 'invalid-state');
187
+ const {
188
+ tokens,
189
+ profile
190
+ } = await oauth.callback(provider, String(code));
191
+ if (!profile.email) return fail(res, 'no-email');
192
+ const requestContext = await context.withRequest(req, res);
193
+ const sudo = requestContext.sudo();
194
+ const existing = await sudo.db[listKey].findMany({
195
+ where: {
196
+ [identityField]: {
197
+ equals: profile.email
198
+ }
199
+ },
200
+ take: 1
201
+ });
202
+ let itemId;
203
+ let created = false;
204
+ if (existing.length) {
205
+ itemId = String(existing[0].id);
206
+ } else {
207
+ if (!allowSignup) return fail(res, 'unknown-user');
208
+ const data = resolveUser ? await resolveUser({
209
+ provider,
210
+ profile,
211
+ tokens,
212
+ context: requestContext
213
+ }) : {
214
+ [identityField]: profile.email
215
+ };
216
+ if (!data) return fail(res, 'signup-rejected');
217
+ const createdItem = await sudo.db[listKey].createOne({
218
+ data: data
219
+ });
220
+ itemId = String(createdItem.id);
221
+ created = true;
222
+ }
223
+ if (!requestContext.sessionStrategy) {
224
+ throw new Error('installOAuthRoutes: no session strategy configured');
225
+ }
226
+ await requestContext.sessionStrategy.start({
227
+ context: requestContext,
228
+ data: {
229
+ itemId
230
+ }
231
+ });
232
+ await (onSignIn === null || onSignIn === void 0 ? void 0 : onSignIn({
233
+ provider,
234
+ itemId,
235
+ created,
236
+ profile,
237
+ tokens,
238
+ context: requestContext
239
+ }));
240
+ res.redirect(successRedirect);
241
+ })().catch(err => {
242
+ console.error('[nixxie/auth-oauth] callback failed:', err);
243
+ fail(res, 'oauth-failed');
244
+ });
245
+ });
246
+ }
247
+
248
+ /**
249
+ * The same OAuth routes as `installOAuthRoutes`, packaged as a Nixxie plugin.
250
+ *
251
+ * @example
252
+ * config({
253
+ * plugins: [
254
+ * oauthPlugin({
255
+ * oauth: createOAuth({ providers: { google: { provider: 'google', ... } } }),
256
+ * listKey: 'User',
257
+ * }),
258
+ * ],
259
+ * })
260
+ */
261
+ function oauthPlugin(options) {
262
+ return {
263
+ name: 'nixxie-auth-oauth',
264
+ extendConfig: config => ({
265
+ ...config,
266
+ server: {
267
+ ...config.server,
268
+ extendExpressApp: async (app, context) => {
269
+ var _config$server, _config$server$extend;
270
+ await ((_config$server = config.server) === null || _config$server === void 0 || (_config$server$extend = _config$server.extendExpressApp) === null || _config$server$extend === void 0 ? void 0 : _config$server$extend.call(_config$server, app, context));
271
+ installOAuthRoutes(app, context, options);
272
+ }
273
+ }
274
+ })
275
+ };
276
+ }
277
+
100
278
  function resolveDefinition(cfg) {
101
279
  if (cfg.definition) return cfg.definition;
102
280
  if (cfg.provider) return providers[cfg.provider];
@@ -214,4 +392,6 @@ function createOAuth(config) {
214
392
 
215
393
  exports.OAuth = OAuth;
216
394
  exports.createOAuth = createOAuth;
395
+ exports.installOAuthRoutes = installOAuthRoutes;
396
+ exports.oauthPlugin = oauthPlugin;
217
397
  exports.providers = providers;
@@ -1,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, createHmac } from 'node:crypto';
2
2
 
3
3
  const providers = {
4
4
  google: {
@@ -93,6 +93,184 @@ const providers = {
93
93
  }
94
94
  };
95
95
 
96
+ const STATE_COOKIE = 'nixxie-oauth-state';
97
+ function parseCookies(header) {
98
+ const out = {};
99
+ if (!header) return out;
100
+ for (const part of header.split(';')) {
101
+ const eq = part.indexOf('=');
102
+ if (eq === -1) continue;
103
+ out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Mount ready-made OAuth sign-in routes on the Express app:
110
+ *
111
+ * - `GET <basePath>/:provider` — redirects to the provider's consent screen
112
+ * - `GET <basePath>/:provider/callback` — exchanges the code, finds-or-creates the
113
+ * user, starts a session (compatible with `createAuth` sessions: `{ itemId }`),
114
+ * and redirects
115
+ *
116
+ * Call from `server.extendExpressApp`, or use `oauthPlugin()` to wire it automatically.
117
+ */
118
+ function installOAuthRoutes(app, context, options) {
119
+ const {
120
+ oauth,
121
+ listKey = 'User',
122
+ identityField = 'email',
123
+ allowSignup = true,
124
+ resolveUser,
125
+ onSignIn,
126
+ basePath = '/auth',
127
+ successRedirect = '/',
128
+ failureRedirect = '/signin'
129
+ } = options;
130
+
131
+ // The state value is signed so the callback can verify it even though the cookie
132
+ // itself is the only storage — no server-side flow state needed.
133
+ const stateSecret = randomBytes(32).toString('hex');
134
+ const signState = value => `${value}.${createHmac('sha256', stateSecret).update(value).digest('hex')}`;
135
+ const verifyState = signed => {
136
+ if (!signed) return;
137
+ const at = signed.lastIndexOf('.');
138
+ if (at === -1) return;
139
+ const value = signed.slice(0, at);
140
+ const mac = createHmac('sha256', stateSecret).update(value).digest('hex');
141
+ return mac === signed.slice(at + 1) ? value : undefined;
142
+ };
143
+ const fail = (res, code) => {
144
+ const sep = failureRedirect.includes('?') ? '&' : '?';
145
+ res.redirect(`${failureRedirect}${sep}error=${encodeURIComponent(code)}`);
146
+ };
147
+ app.get(`${basePath}/:provider`, (req, res) => {
148
+ try {
149
+ var _res$cookie;
150
+ const provider = req.params.provider;
151
+ if (!oauth.list().includes(provider)) return fail(res, 'unknown-provider');
152
+ const state = oauth.createState();
153
+ (_res$cookie = res.cookie) === null || _res$cookie === void 0 || _res$cookie.call(res, STATE_COOKIE, signState(`${provider}:${state}`), {
154
+ httpOnly: true,
155
+ sameSite: 'lax',
156
+ secure: process.env.NODE_ENV === 'production',
157
+ maxAge: 10 * 60 * 1000,
158
+ path: basePath
159
+ });
160
+ res.redirect(oauth.authorizationUrl(provider, {
161
+ state
162
+ }));
163
+ } catch (err) {
164
+ console.error('[nixxie/auth-oauth] start failed:', err);
165
+ fail(res, 'oauth-start-failed');
166
+ }
167
+ });
168
+ app.get(`${basePath}/:provider/callback`, (req, res) => {
169
+ void (async (_req$query, _res$clearCookie) => {
170
+ const provider = req.params.provider;
171
+ const {
172
+ code,
173
+ state,
174
+ error
175
+ } = (_req$query = req.query) !== null && _req$query !== void 0 ? _req$query : {};
176
+ if (error) return fail(res, String(error));
177
+ if (!code) return fail(res, 'missing-code');
178
+ const stored = verifyState(parseCookies(req.headers.cookie)[STATE_COOKIE]);
179
+ (_res$clearCookie = res.clearCookie) === null || _res$clearCookie === void 0 || _res$clearCookie.call(res, STATE_COOKIE, {
180
+ path: basePath
181
+ });
182
+ if (!stored || stored !== `${provider}:${state}`) return fail(res, 'invalid-state');
183
+ const {
184
+ tokens,
185
+ profile
186
+ } = await oauth.callback(provider, String(code));
187
+ if (!profile.email) return fail(res, 'no-email');
188
+ const requestContext = await context.withRequest(req, res);
189
+ const sudo = requestContext.sudo();
190
+ const existing = await sudo.db[listKey].findMany({
191
+ where: {
192
+ [identityField]: {
193
+ equals: profile.email
194
+ }
195
+ },
196
+ take: 1
197
+ });
198
+ let itemId;
199
+ let created = false;
200
+ if (existing.length) {
201
+ itemId = String(existing[0].id);
202
+ } else {
203
+ if (!allowSignup) return fail(res, 'unknown-user');
204
+ const data = resolveUser ? await resolveUser({
205
+ provider,
206
+ profile,
207
+ tokens,
208
+ context: requestContext
209
+ }) : {
210
+ [identityField]: profile.email
211
+ };
212
+ if (!data) return fail(res, 'signup-rejected');
213
+ const createdItem = await sudo.db[listKey].createOne({
214
+ data: data
215
+ });
216
+ itemId = String(createdItem.id);
217
+ created = true;
218
+ }
219
+ if (!requestContext.sessionStrategy) {
220
+ throw new Error('installOAuthRoutes: no session strategy configured');
221
+ }
222
+ await requestContext.sessionStrategy.start({
223
+ context: requestContext,
224
+ data: {
225
+ itemId
226
+ }
227
+ });
228
+ await (onSignIn === null || onSignIn === void 0 ? void 0 : onSignIn({
229
+ provider,
230
+ itemId,
231
+ created,
232
+ profile,
233
+ tokens,
234
+ context: requestContext
235
+ }));
236
+ res.redirect(successRedirect);
237
+ })().catch(err => {
238
+ console.error('[nixxie/auth-oauth] callback failed:', err);
239
+ fail(res, 'oauth-failed');
240
+ });
241
+ });
242
+ }
243
+
244
+ /**
245
+ * The same OAuth routes as `installOAuthRoutes`, packaged as a Nixxie plugin.
246
+ *
247
+ * @example
248
+ * config({
249
+ * plugins: [
250
+ * oauthPlugin({
251
+ * oauth: createOAuth({ providers: { google: { provider: 'google', ... } } }),
252
+ * listKey: 'User',
253
+ * }),
254
+ * ],
255
+ * })
256
+ */
257
+ function oauthPlugin(options) {
258
+ return {
259
+ name: 'nixxie-auth-oauth',
260
+ extendConfig: config => ({
261
+ ...config,
262
+ server: {
263
+ ...config.server,
264
+ extendExpressApp: async (app, context) => {
265
+ var _config$server, _config$server$extend;
266
+ await ((_config$server = config.server) === null || _config$server === void 0 || (_config$server$extend = _config$server.extendExpressApp) === null || _config$server$extend === void 0 ? void 0 : _config$server$extend.call(_config$server, app, context));
267
+ installOAuthRoutes(app, context, options);
268
+ }
269
+ }
270
+ })
271
+ };
272
+ }
273
+
96
274
  function resolveDefinition(cfg) {
97
275
  if (cfg.definition) return cfg.definition;
98
276
  if (cfg.provider) return providers[cfg.provider];
@@ -208,4 +386,4 @@ function createOAuth(config) {
208
386
  return new OAuth(config);
209
387
  }
210
388
 
211
- export { OAuth, createOAuth, providers };
389
+ export { OAuth, createOAuth, installOAuthRoutes, oauthPlugin, providers };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nixxie-cms/auth-oauth",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/nixxie-cms-auth-oauth.cjs.js",
6
6
  "module": "dist/nixxie-cms-auth-oauth.esm.js",
@@ -16,7 +16,7 @@
16
16
  "@babel/runtime": "^7.24.7"
17
17
  },
18
18
  "devDependencies": {
19
- "@nixxie-cms/core": "^1.0.1"
19
+ "@nixxie-cms/core": "^1.1.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@nixxie-cms/core": "^1.0.1"
package/src/express.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { createHmac, randomBytes } from 'node:crypto'
2
+ import type { NixxieContext, NixxiePlugin } from '@nixxie-cms/core/types'
3
+ import type { OAuth } from './index'
4
+ import type { OAuthProfile, OAuthTokens } from './types'
5
+
6
+ const STATE_COOKIE = 'nixxie-oauth-state'
7
+
8
+ export type OAuthRoutesOptions = {
9
+ /** The OAuth client created with `createOAuth()`. */
10
+ oauth: OAuth
11
+ /**
12
+ * Collection holding your users.
13
+ * @default 'User'
14
+ */
15
+ listKey?: string
16
+ /**
17
+ * Field used to match OAuth profiles to existing users (by the profile's email).
18
+ * @default 'email'
19
+ */
20
+ identityField?: string
21
+ /**
22
+ * Create users on first sign-in. When false, sign-ins from unknown
23
+ * profiles are redirected to `failureRedirect`.
24
+ * @default true
25
+ */
26
+ allowSignup?: boolean
27
+ /**
28
+ * Resolve the data for a user being created on first sign-in.
29
+ * Return null to reject the sign-in. Defaults to `{ [identityField]: profile.email }`
30
+ * plus `name` when the collection's input accepts it.
31
+ */
32
+ resolveUser?: (args: {
33
+ provider: string
34
+ profile: OAuthProfile
35
+ tokens: OAuthTokens
36
+ context: NixxieContext
37
+ }) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null
38
+ /**
39
+ * Called after a successful sign-in (e.g. to link accounts or audit). Receives the
40
+ * signed-in user's id.
41
+ */
42
+ onSignIn?: (args: {
43
+ provider: string
44
+ itemId: string
45
+ created: boolean
46
+ profile: OAuthProfile
47
+ tokens: OAuthTokens
48
+ context: NixxieContext
49
+ }) => Promise<void> | void
50
+ /**
51
+ * Base path the routes are mounted under: `<basePath>/:provider` starts the flow and
52
+ * `<basePath>/:provider/callback` completes it.
53
+ * @default '/auth'
54
+ */
55
+ basePath?: string
56
+ /** Where to send the user after a successful sign-in. @default '/' */
57
+ successRedirect?: string
58
+ /** Where to send the user after a failure (gets `?error=<code>` appended). @default '/signin' */
59
+ failureRedirect?: string
60
+ }
61
+
62
+ function parseCookies(header: string | undefined): Record<string, string> {
63
+ const out: Record<string, string> = {}
64
+ if (!header) return out
65
+ for (const part of header.split(';')) {
66
+ const eq = part.indexOf('=')
67
+ if (eq === -1) continue
68
+ out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim())
69
+ }
70
+ return out
71
+ }
72
+
73
+ /**
74
+ * Mount ready-made OAuth sign-in routes on the Express app:
75
+ *
76
+ * - `GET <basePath>/:provider` — redirects to the provider's consent screen
77
+ * - `GET <basePath>/:provider/callback` — exchanges the code, finds-or-creates the
78
+ * user, starts a session (compatible with `createAuth` sessions: `{ itemId }`),
79
+ * and redirects
80
+ *
81
+ * Call from `server.extendExpressApp`, or use `oauthPlugin()` to wire it automatically.
82
+ */
83
+ export function installOAuthRoutes(
84
+ app: any,
85
+ context: NixxieContext,
86
+ options: OAuthRoutesOptions
87
+ ): void {
88
+ const {
89
+ oauth,
90
+ listKey = 'User',
91
+ identityField = 'email',
92
+ allowSignup = true,
93
+ resolveUser,
94
+ onSignIn,
95
+ basePath = '/auth',
96
+ successRedirect = '/',
97
+ failureRedirect = '/signin',
98
+ } = options
99
+
100
+ // The state value is signed so the callback can verify it even though the cookie
101
+ // itself is the only storage — no server-side flow state needed.
102
+ const stateSecret = randomBytes(32).toString('hex')
103
+ const signState = (value: string) =>
104
+ `${value}.${createHmac('sha256', stateSecret).update(value).digest('hex')}`
105
+ const verifyState = (signed: string | undefined): string | undefined => {
106
+ if (!signed) return
107
+ const at = signed.lastIndexOf('.')
108
+ if (at === -1) return
109
+ const value = signed.slice(0, at)
110
+ const mac = createHmac('sha256', stateSecret).update(value).digest('hex')
111
+ return mac === signed.slice(at + 1) ? value : undefined
112
+ }
113
+
114
+ const fail = (res: any, code: string) => {
115
+ const sep = failureRedirect.includes('?') ? '&' : '?'
116
+ res.redirect(`${failureRedirect}${sep}error=${encodeURIComponent(code)}`)
117
+ }
118
+
119
+ app.get(`${basePath}/:provider`, (req: any, res: any) => {
120
+ try {
121
+ const provider = req.params.provider
122
+ if (!oauth.list().includes(provider)) return fail(res, 'unknown-provider')
123
+
124
+ const state = oauth.createState()
125
+ res.cookie?.(STATE_COOKIE, signState(`${provider}:${state}`), {
126
+ httpOnly: true,
127
+ sameSite: 'lax',
128
+ secure: process.env.NODE_ENV === 'production',
129
+ maxAge: 10 * 60 * 1000,
130
+ path: basePath,
131
+ })
132
+ res.redirect(oauth.authorizationUrl(provider, { state }))
133
+ } catch (err) {
134
+ console.error('[nixxie/auth-oauth] start failed:', err)
135
+ fail(res, 'oauth-start-failed')
136
+ }
137
+ })
138
+
139
+ app.get(`${basePath}/:provider/callback`, (req: any, res: any) => {
140
+ void (async () => {
141
+ const provider = req.params.provider
142
+ const { code, state, error } = req.query ?? {}
143
+ if (error) return fail(res, String(error))
144
+ if (!code) return fail(res, 'missing-code')
145
+
146
+ const stored = verifyState(parseCookies(req.headers.cookie)[STATE_COOKIE])
147
+ res.clearCookie?.(STATE_COOKIE, { path: basePath })
148
+ if (!stored || stored !== `${provider}:${state}`) return fail(res, 'invalid-state')
149
+
150
+ const { tokens, profile } = await oauth.callback(provider, String(code))
151
+ if (!profile.email) return fail(res, 'no-email')
152
+
153
+ const requestContext = await context.withRequest(req, res)
154
+ const sudo = requestContext.sudo()
155
+
156
+ const existing = await sudo.db[listKey].findMany({
157
+ where: { [identityField]: { equals: profile.email } } as any,
158
+ take: 1,
159
+ })
160
+
161
+ let itemId: string
162
+ let created = false
163
+ if (existing.length) {
164
+ itemId = String(existing[0].id)
165
+ } else {
166
+ if (!allowSignup) return fail(res, 'unknown-user')
167
+ const data = resolveUser
168
+ ? await resolveUser({ provider, profile, tokens, context: requestContext })
169
+ : { [identityField]: profile.email }
170
+ if (!data) return fail(res, 'signup-rejected')
171
+ const createdItem = await sudo.db[listKey].createOne({ data: data as any })
172
+ itemId = String(createdItem.id)
173
+ created = true
174
+ }
175
+
176
+ if (!requestContext.sessionStrategy) {
177
+ throw new Error('installOAuthRoutes: no session strategy configured')
178
+ }
179
+ await requestContext.sessionStrategy.start({
180
+ context: requestContext,
181
+ data: { itemId } as any,
182
+ })
183
+
184
+ await onSignIn?.({ provider, itemId, created, profile, tokens, context: requestContext })
185
+ res.redirect(successRedirect)
186
+ })().catch((err: unknown) => {
187
+ console.error('[nixxie/auth-oauth] callback failed:', err)
188
+ fail(res, 'oauth-failed')
189
+ })
190
+ })
191
+ }
192
+
193
+ /**
194
+ * The same OAuth routes as `installOAuthRoutes`, packaged as a Nixxie plugin.
195
+ *
196
+ * @example
197
+ * config({
198
+ * plugins: [
199
+ * oauthPlugin({
200
+ * oauth: createOAuth({ providers: { google: { provider: 'google', ... } } }),
201
+ * listKey: 'User',
202
+ * }),
203
+ * ],
204
+ * })
205
+ */
206
+ export function oauthPlugin(options: OAuthRoutesOptions): NixxiePlugin {
207
+ return {
208
+ name: 'nixxie-auth-oauth',
209
+ extendConfig: config => ({
210
+ ...config,
211
+ server: {
212
+ ...config.server,
213
+ extendExpressApp: async (app: any, context: NixxieContext) => {
214
+ await (config.server as any)?.extendExpressApp?.(app, context)
215
+ installOAuthRoutes(app, context, options)
216
+ },
217
+ },
218
+ }),
219
+ }
220
+ }
package/src/index.ts CHANGED
@@ -110,6 +110,8 @@ export function createOAuth(config: OAuthConfig): OAuth {
110
110
  return new OAuth(config)
111
111
  }
112
112
 
113
+ export { installOAuthRoutes, oauthPlugin } from './express'
114
+ export type { OAuthRoutesOptions } from './express'
113
115
  export { builtInProviders as providers }
114
116
  export type {
115
117
  OAuthConfig,