@nixxie-cms/auth-oauth 1.0.1

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 ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixxie International DMCC
4
+ Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
5
+ (this software is derived from the KeystoneJS project, https://keystonejs.com)
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @nixxie-cms/auth-oauth
2
+
3
+ Social / OAuth 2.0 sign-in helpers for Nixxie CMS. Built-in providers for Google, GitHub, GitLab,
4
+ Discord, Microsoft and Facebook, plus a `definition` escape hatch for any OAuth 2.0 / OIDC
5
+ provider. No SDK dependencies — it talks to the provider endpoints with `fetch`.
6
+
7
+ ```ts
8
+ import { createOAuth } from '@nixxie-cms/auth-oauth'
9
+
10
+ const oauth = createOAuth({
11
+ providers: {
12
+ google: {
13
+ provider: 'google',
14
+ clientId: process.env.GOOGLE_CLIENT_ID!,
15
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
16
+ redirectUri: 'http://localhost:3000/auth/google/callback',
17
+ },
18
+ },
19
+ })
20
+ ```
21
+
22
+ Wire it into Express via `server.extendExpressApp`:
23
+
24
+ ```ts
25
+ app.get('/auth/google', (req, res) => {
26
+ const state = oauth.createState()
27
+ // persist `state` in the session, then:
28
+ res.redirect(oauth.authorizationUrl('google', { state }))
29
+ })
30
+
31
+ app.get('/auth/google/callback', async (req, res) => {
32
+ const { profile } = await oauth.callback('google', req.query.code as string)
33
+ // upsert a user keyed by profile.id / profile.email and start a session
34
+ })
35
+ ```
@@ -0,0 +1,29 @@
1
+ import { providers as builtInProviders } from "./providers.js";
2
+ import type { OAuthConfig, OAuthProfile, OAuthTokens } from "./types.js";
3
+ export declare class OAuth {
4
+ private config;
5
+ constructor(config: OAuthConfig);
6
+ /** Names of the configured providers. */
7
+ list(): string[];
8
+ private get;
9
+ /** Generate a random `state` value to protect against CSRF. Persist it in the session. */
10
+ createState(): string;
11
+ /** Build the URL to redirect the user to in order to begin the flow. */
12
+ authorizationUrl(name: string, options?: {
13
+ state?: string;
14
+ extraParams?: Record<string, string>;
15
+ }): string;
16
+ /** Exchange the authorization `code` returned to your callback for tokens. */
17
+ exchangeCode(name: string, code: string): Promise<OAuthTokens>;
18
+ /** Fetch the normalised user profile using an access token. */
19
+ fetchProfile(name: string, tokens: OAuthTokens): Promise<OAuthProfile>;
20
+ /** Convenience: exchange the code and fetch the profile in one call. */
21
+ callback(name: string, code: string): Promise<{
22
+ tokens: OAuthTokens;
23
+ profile: OAuthProfile;
24
+ }>;
25
+ }
26
+ export declare function createOAuth(config: OAuthConfig): OAuth;
27
+ export { builtInProviders as providers };
28
+ export type { OAuthConfig, OAuthProviderConfig, OAuthProviderDefinition, OAuthProviderName, OAuthProfile, OAuthTokens, } from "./types.js";
29
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,3 @@
1
+ import type { OAuthProviderDefinition, OAuthProviderName } from "./types.js";
2
+ export declare const providers: Record<OAuthProviderName, OAuthProviderDefinition>;
3
+ //# sourceMappingURL=providers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers.d.ts","sourceRoot":"../../../src","sources":["providers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,iBAAiB,EAAE,mBAAe;AAEzE,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,iBAAiB,EAAE,uBAAuB,CAiFxE,CAAA"}
@@ -0,0 +1,48 @@
1
+ export type OAuthProviderName = 'google' | 'github' | 'gitlab' | 'discord' | 'microsoft' | 'facebook';
2
+ /** Endpoints + behaviour for an OAuth 2.0 / OpenID Connect provider. */
3
+ export type OAuthProviderDefinition = {
4
+ /** Authorization endpoint (where the user is redirected to consent). */
5
+ authorizationUrl: string;
6
+ /** Token endpoint (where the auth code is exchanged for tokens). */
7
+ tokenUrl: string;
8
+ /** Userinfo endpoint used to fetch the profile. */
9
+ userInfoUrl: string;
10
+ /** Default scopes requested when the caller doesn't override them. */
11
+ defaultScopes: string[];
12
+ /** Normalise the raw userinfo response into a common profile shape. */
13
+ profile: (raw: any) => OAuthProfile;
14
+ };
15
+ export type OAuthProfile = {
16
+ /** Stable provider-specific user id. */
17
+ id: string;
18
+ email?: string;
19
+ name?: string;
20
+ avatarUrl?: string;
21
+ /** The untouched provider response. */
22
+ raw: any;
23
+ };
24
+ export type OAuthTokens = {
25
+ accessToken: string;
26
+ refreshToken?: string;
27
+ expiresIn?: number;
28
+ tokenType?: string;
29
+ scope?: string;
30
+ idToken?: string;
31
+ };
32
+ export type OAuthProviderConfig = {
33
+ /** Built-in provider name, or omit and supply `definition` for a custom provider. */
34
+ provider?: OAuthProviderName;
35
+ /** Custom provider definition (overrides `provider`). */
36
+ definition?: OAuthProviderDefinition;
37
+ clientId: string;
38
+ clientSecret: string;
39
+ /** Redirect/callback URL registered with the provider. */
40
+ redirectUri: string;
41
+ /** Override the provider's default scopes. */
42
+ scopes?: string[];
43
+ };
44
+ export type OAuthConfig = {
45
+ /** Map of named providers, keyed by however you want to reference them in routes. */
46
+ providers: Record<string, OAuthProviderConfig>;
47
+ };
48
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"../../../src","sources":["types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,WAAW,GACX,UAAU,CAAA;AAEd,wEAAwE;AACxE,MAAM,MAAM,uBAAuB,GAAG;IACpC,wEAAwE;IACxE,gBAAgB,EAAE,MAAM,CAAA;IACxB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAA;IAChB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,uEAAuE;IACvE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,YAAY,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uCAAuC;IACvC,GAAG,EAAE,GAAG,CAAA;CACT,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,qFAAqF;IACrF,QAAQ,CAAC,EAAE,iBAAiB,CAAA;IAC5B,yDAAyD;IACzD,UAAU,CAAC,EAAE,uBAAuB,CAAA;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAA;IACnB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,qFAAqF;IACrF,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;CAC/C,CAAA"}
@@ -0,0 +1,2 @@
1
+ export * from "./declarations/src/index.js";
2
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1hdXRoLW9hdXRoLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi9kZWNsYXJhdGlvbnMvc3JjL2luZGV4LmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ==
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var node_crypto = require('node:crypto');
6
+
7
+ const providers = {
8
+ google: {
9
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
10
+ tokenUrl: 'https://oauth2.googleapis.com/token',
11
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
12
+ defaultScopes: ['openid', 'email', 'profile'],
13
+ profile: raw => ({
14
+ id: raw.sub,
15
+ email: raw.email,
16
+ name: raw.name,
17
+ avatarUrl: raw.picture,
18
+ raw
19
+ })
20
+ },
21
+ github: {
22
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
23
+ tokenUrl: 'https://github.com/login/oauth/access_token',
24
+ userInfoUrl: 'https://api.github.com/user',
25
+ defaultScopes: ['read:user', 'user:email'],
26
+ profile: raw => {
27
+ var _raw$name;
28
+ return {
29
+ id: String(raw.id),
30
+ email: raw.email,
31
+ name: (_raw$name = raw.name) !== null && _raw$name !== void 0 ? _raw$name : raw.login,
32
+ avatarUrl: raw.avatar_url,
33
+ raw
34
+ };
35
+ }
36
+ },
37
+ gitlab: {
38
+ authorizationUrl: 'https://gitlab.com/oauth/authorize',
39
+ tokenUrl: 'https://gitlab.com/oauth/token',
40
+ userInfoUrl: 'https://gitlab.com/api/v4/user',
41
+ defaultScopes: ['read_user'],
42
+ profile: raw => {
43
+ var _raw$name2;
44
+ return {
45
+ id: String(raw.id),
46
+ email: raw.email,
47
+ name: (_raw$name2 = raw.name) !== null && _raw$name2 !== void 0 ? _raw$name2 : raw.username,
48
+ avatarUrl: raw.avatar_url,
49
+ raw
50
+ };
51
+ }
52
+ },
53
+ discord: {
54
+ authorizationUrl: 'https://discord.com/oauth2/authorize',
55
+ tokenUrl: 'https://discord.com/api/oauth2/token',
56
+ userInfoUrl: 'https://discord.com/api/users/@me',
57
+ defaultScopes: ['identify', 'email'],
58
+ profile: raw => {
59
+ var _raw$global_name;
60
+ return {
61
+ id: raw.id,
62
+ email: raw.email,
63
+ name: (_raw$global_name = raw.global_name) !== null && _raw$global_name !== void 0 ? _raw$global_name : raw.username,
64
+ avatarUrl: raw.avatar ? `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png` : undefined,
65
+ raw
66
+ };
67
+ }
68
+ },
69
+ microsoft: {
70
+ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
71
+ tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
72
+ userInfoUrl: 'https://graph.microsoft.com/oidc/userinfo',
73
+ defaultScopes: ['openid', 'email', 'profile'],
74
+ profile: raw => ({
75
+ id: raw.sub,
76
+ email: raw.email,
77
+ name: raw.name,
78
+ avatarUrl: raw.picture,
79
+ raw
80
+ })
81
+ },
82
+ facebook: {
83
+ authorizationUrl: 'https://www.facebook.com/v19.0/dialog/oauth',
84
+ tokenUrl: 'https://graph.facebook.com/v19.0/oauth/access_token',
85
+ userInfoUrl: 'https://graph.facebook.com/me?fields=id,name,email,picture',
86
+ defaultScopes: ['email', 'public_profile'],
87
+ profile: raw => {
88
+ var _raw$picture;
89
+ return {
90
+ id: raw.id,
91
+ email: raw.email,
92
+ name: raw.name,
93
+ avatarUrl: (_raw$picture = raw.picture) === null || _raw$picture === void 0 || (_raw$picture = _raw$picture.data) === null || _raw$picture === void 0 ? void 0 : _raw$picture.url,
94
+ raw
95
+ };
96
+ }
97
+ }
98
+ };
99
+
100
+ function resolveDefinition(cfg) {
101
+ if (cfg.definition) return cfg.definition;
102
+ if (cfg.provider) return providers[cfg.provider];
103
+ throw new Error('OAuth provider config must specify either `provider` or `definition`');
104
+ }
105
+ class OAuth {
106
+ constructor(config) {
107
+ this.config = config;
108
+ }
109
+
110
+ /** Names of the configured providers. */
111
+ list() {
112
+ return Object.keys(this.config.providers);
113
+ }
114
+ get(name) {
115
+ const cfg = this.config.providers[name];
116
+ if (!cfg) throw new Error(`Unknown OAuth provider: ${name}`);
117
+ return {
118
+ cfg,
119
+ def: resolveDefinition(cfg)
120
+ };
121
+ }
122
+
123
+ /** Generate a random `state` value to protect against CSRF. Persist it in the session. */
124
+ createState() {
125
+ return node_crypto.randomBytes(16).toString('hex');
126
+ }
127
+
128
+ /** Build the URL to redirect the user to in order to begin the flow. */
129
+ authorizationUrl(name, options) {
130
+ var _cfg$scopes;
131
+ const {
132
+ cfg,
133
+ def
134
+ } = this.get(name);
135
+ const params = new URLSearchParams({
136
+ response_type: 'code',
137
+ client_id: cfg.clientId,
138
+ redirect_uri: cfg.redirectUri,
139
+ scope: ((_cfg$scopes = cfg.scopes) !== null && _cfg$scopes !== void 0 ? _cfg$scopes : def.defaultScopes).join(' '),
140
+ ...(options !== null && options !== void 0 && options.state ? {
141
+ state: options.state
142
+ } : {}),
143
+ ...(options === null || options === void 0 ? void 0 : options.extraParams)
144
+ });
145
+ return `${def.authorizationUrl}?${params}`;
146
+ }
147
+
148
+ /** Exchange the authorization `code` returned to your callback for tokens. */
149
+ async exchangeCode(name, code) {
150
+ var _data$error_descripti;
151
+ const {
152
+ cfg,
153
+ def
154
+ } = this.get(name);
155
+ const res = await fetch(def.tokenUrl, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/x-www-form-urlencoded',
159
+ Accept: 'application/json',
160
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
161
+ 'User-Agent': 'nixxie-cms-oauth'
162
+ },
163
+ body: new URLSearchParams({
164
+ grant_type: 'authorization_code',
165
+ code,
166
+ client_id: cfg.clientId,
167
+ client_secret: cfg.clientSecret,
168
+ redirect_uri: cfg.redirectUri
169
+ })
170
+ });
171
+ if (!res.ok) throw new Error(`OAuth token exchange failed (${res.status}): ${await res.text()}`);
172
+ const data = await res.json();
173
+ if (data.error) throw new Error(`OAuth token exchange error: ${(_data$error_descripti = data.error_description) !== null && _data$error_descripti !== void 0 ? _data$error_descripti : data.error}`);
174
+ return {
175
+ accessToken: data.access_token,
176
+ refreshToken: data.refresh_token,
177
+ expiresIn: data.expires_in,
178
+ tokenType: data.token_type,
179
+ scope: data.scope,
180
+ idToken: data.id_token
181
+ };
182
+ }
183
+
184
+ /** Fetch the normalised user profile using an access token. */
185
+ async fetchProfile(name, tokens) {
186
+ const {
187
+ def
188
+ } = this.get(name);
189
+ const res = await fetch(def.userInfoUrl, {
190
+ headers: {
191
+ Authorization: `Bearer ${tokens.accessToken}`,
192
+ Accept: 'application/json',
193
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
194
+ 'User-Agent': 'nixxie-cms-oauth'
195
+ }
196
+ });
197
+ if (!res.ok) throw new Error(`OAuth userinfo failed (${res.status}): ${await res.text()}`);
198
+ return def.profile(await res.json());
199
+ }
200
+
201
+ /** Convenience: exchange the code and fetch the profile in one call. */
202
+ async callback(name, code) {
203
+ const tokens = await this.exchangeCode(name, code);
204
+ const profile = await this.fetchProfile(name, tokens);
205
+ return {
206
+ tokens,
207
+ profile
208
+ };
209
+ }
210
+ }
211
+ function createOAuth(config) {
212
+ return new OAuth(config);
213
+ }
214
+
215
+ exports.OAuth = OAuth;
216
+ exports.createOAuth = createOAuth;
217
+ exports.providers = providers;
@@ -0,0 +1,211 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const providers = {
4
+ google: {
5
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
6
+ tokenUrl: 'https://oauth2.googleapis.com/token',
7
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
8
+ defaultScopes: ['openid', 'email', 'profile'],
9
+ profile: raw => ({
10
+ id: raw.sub,
11
+ email: raw.email,
12
+ name: raw.name,
13
+ avatarUrl: raw.picture,
14
+ raw
15
+ })
16
+ },
17
+ github: {
18
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
19
+ tokenUrl: 'https://github.com/login/oauth/access_token',
20
+ userInfoUrl: 'https://api.github.com/user',
21
+ defaultScopes: ['read:user', 'user:email'],
22
+ profile: raw => {
23
+ var _raw$name;
24
+ return {
25
+ id: String(raw.id),
26
+ email: raw.email,
27
+ name: (_raw$name = raw.name) !== null && _raw$name !== void 0 ? _raw$name : raw.login,
28
+ avatarUrl: raw.avatar_url,
29
+ raw
30
+ };
31
+ }
32
+ },
33
+ gitlab: {
34
+ authorizationUrl: 'https://gitlab.com/oauth/authorize',
35
+ tokenUrl: 'https://gitlab.com/oauth/token',
36
+ userInfoUrl: 'https://gitlab.com/api/v4/user',
37
+ defaultScopes: ['read_user'],
38
+ profile: raw => {
39
+ var _raw$name2;
40
+ return {
41
+ id: String(raw.id),
42
+ email: raw.email,
43
+ name: (_raw$name2 = raw.name) !== null && _raw$name2 !== void 0 ? _raw$name2 : raw.username,
44
+ avatarUrl: raw.avatar_url,
45
+ raw
46
+ };
47
+ }
48
+ },
49
+ discord: {
50
+ authorizationUrl: 'https://discord.com/oauth2/authorize',
51
+ tokenUrl: 'https://discord.com/api/oauth2/token',
52
+ userInfoUrl: 'https://discord.com/api/users/@me',
53
+ defaultScopes: ['identify', 'email'],
54
+ profile: raw => {
55
+ var _raw$global_name;
56
+ return {
57
+ id: raw.id,
58
+ email: raw.email,
59
+ name: (_raw$global_name = raw.global_name) !== null && _raw$global_name !== void 0 ? _raw$global_name : raw.username,
60
+ avatarUrl: raw.avatar ? `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png` : undefined,
61
+ raw
62
+ };
63
+ }
64
+ },
65
+ microsoft: {
66
+ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
67
+ tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
68
+ userInfoUrl: 'https://graph.microsoft.com/oidc/userinfo',
69
+ defaultScopes: ['openid', 'email', 'profile'],
70
+ profile: raw => ({
71
+ id: raw.sub,
72
+ email: raw.email,
73
+ name: raw.name,
74
+ avatarUrl: raw.picture,
75
+ raw
76
+ })
77
+ },
78
+ facebook: {
79
+ authorizationUrl: 'https://www.facebook.com/v19.0/dialog/oauth',
80
+ tokenUrl: 'https://graph.facebook.com/v19.0/oauth/access_token',
81
+ userInfoUrl: 'https://graph.facebook.com/me?fields=id,name,email,picture',
82
+ defaultScopes: ['email', 'public_profile'],
83
+ profile: raw => {
84
+ var _raw$picture;
85
+ return {
86
+ id: raw.id,
87
+ email: raw.email,
88
+ name: raw.name,
89
+ avatarUrl: (_raw$picture = raw.picture) === null || _raw$picture === void 0 || (_raw$picture = _raw$picture.data) === null || _raw$picture === void 0 ? void 0 : _raw$picture.url,
90
+ raw
91
+ };
92
+ }
93
+ }
94
+ };
95
+
96
+ function resolveDefinition(cfg) {
97
+ if (cfg.definition) return cfg.definition;
98
+ if (cfg.provider) return providers[cfg.provider];
99
+ throw new Error('OAuth provider config must specify either `provider` or `definition`');
100
+ }
101
+ class OAuth {
102
+ constructor(config) {
103
+ this.config = config;
104
+ }
105
+
106
+ /** Names of the configured providers. */
107
+ list() {
108
+ return Object.keys(this.config.providers);
109
+ }
110
+ get(name) {
111
+ const cfg = this.config.providers[name];
112
+ if (!cfg) throw new Error(`Unknown OAuth provider: ${name}`);
113
+ return {
114
+ cfg,
115
+ def: resolveDefinition(cfg)
116
+ };
117
+ }
118
+
119
+ /** Generate a random `state` value to protect against CSRF. Persist it in the session. */
120
+ createState() {
121
+ return randomBytes(16).toString('hex');
122
+ }
123
+
124
+ /** Build the URL to redirect the user to in order to begin the flow. */
125
+ authorizationUrl(name, options) {
126
+ var _cfg$scopes;
127
+ const {
128
+ cfg,
129
+ def
130
+ } = this.get(name);
131
+ const params = new URLSearchParams({
132
+ response_type: 'code',
133
+ client_id: cfg.clientId,
134
+ redirect_uri: cfg.redirectUri,
135
+ scope: ((_cfg$scopes = cfg.scopes) !== null && _cfg$scopes !== void 0 ? _cfg$scopes : def.defaultScopes).join(' '),
136
+ ...(options !== null && options !== void 0 && options.state ? {
137
+ state: options.state
138
+ } : {}),
139
+ ...(options === null || options === void 0 ? void 0 : options.extraParams)
140
+ });
141
+ return `${def.authorizationUrl}?${params}`;
142
+ }
143
+
144
+ /** Exchange the authorization `code` returned to your callback for tokens. */
145
+ async exchangeCode(name, code) {
146
+ var _data$error_descripti;
147
+ const {
148
+ cfg,
149
+ def
150
+ } = this.get(name);
151
+ const res = await fetch(def.tokenUrl, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/x-www-form-urlencoded',
155
+ Accept: 'application/json',
156
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
157
+ 'User-Agent': 'nixxie-cms-oauth'
158
+ },
159
+ body: new URLSearchParams({
160
+ grant_type: 'authorization_code',
161
+ code,
162
+ client_id: cfg.clientId,
163
+ client_secret: cfg.clientSecret,
164
+ redirect_uri: cfg.redirectUri
165
+ })
166
+ });
167
+ if (!res.ok) throw new Error(`OAuth token exchange failed (${res.status}): ${await res.text()}`);
168
+ const data = await res.json();
169
+ if (data.error) throw new Error(`OAuth token exchange error: ${(_data$error_descripti = data.error_description) !== null && _data$error_descripti !== void 0 ? _data$error_descripti : data.error}`);
170
+ return {
171
+ accessToken: data.access_token,
172
+ refreshToken: data.refresh_token,
173
+ expiresIn: data.expires_in,
174
+ tokenType: data.token_type,
175
+ scope: data.scope,
176
+ idToken: data.id_token
177
+ };
178
+ }
179
+
180
+ /** Fetch the normalised user profile using an access token. */
181
+ async fetchProfile(name, tokens) {
182
+ const {
183
+ def
184
+ } = this.get(name);
185
+ const res = await fetch(def.userInfoUrl, {
186
+ headers: {
187
+ Authorization: `Bearer ${tokens.accessToken}`,
188
+ Accept: 'application/json',
189
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
190
+ 'User-Agent': 'nixxie-cms-oauth'
191
+ }
192
+ });
193
+ if (!res.ok) throw new Error(`OAuth userinfo failed (${res.status}): ${await res.text()}`);
194
+ return def.profile(await res.json());
195
+ }
196
+
197
+ /** Convenience: exchange the code and fetch the profile in one call. */
198
+ async callback(name, code) {
199
+ const tokens = await this.exchangeCode(name, code);
200
+ const profile = await this.fetchProfile(name, tokens);
201
+ return {
202
+ tokens,
203
+ profile
204
+ };
205
+ }
206
+ }
207
+ function createOAuth(config) {
208
+ return new OAuth(config);
209
+ }
210
+
211
+ export { OAuth, createOAuth, providers };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nixxie-cms/auth-oauth",
3
+ "version": "1.0.1",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-auth-oauth.cjs.js",
6
+ "module": "dist/nixxie-cms-auth-oauth.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-auth-oauth.cjs.js",
10
+ "module": "./dist/nixxie-cms-auth-oauth.esm.js",
11
+ "default": "./dist/nixxie-cms-auth-oauth.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7"
17
+ },
18
+ "devDependencies": {
19
+ "@nixxie-cms/core": "^1.0.1"
20
+ },
21
+ "peerDependencies": {
22
+ "@nixxie-cms/core": "^1.0.1"
23
+ },
24
+ "preconstruct": {
25
+ "entrypoints": [
26
+ "index.ts"
27
+ ]
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/auth-oauth"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { providers as builtInProviders } from './providers'
3
+ import type {
4
+ OAuthConfig,
5
+ OAuthProfile,
6
+ OAuthProviderConfig,
7
+ OAuthProviderDefinition,
8
+ OAuthTokens,
9
+ } from './types'
10
+
11
+ function resolveDefinition(cfg: OAuthProviderConfig): OAuthProviderDefinition {
12
+ if (cfg.definition) return cfg.definition
13
+ if (cfg.provider) return builtInProviders[cfg.provider]
14
+ throw new Error('OAuth provider config must specify either `provider` or `definition`')
15
+ }
16
+
17
+ export class OAuth {
18
+ private config: OAuthConfig
19
+
20
+ constructor(config: OAuthConfig) {
21
+ this.config = config
22
+ }
23
+
24
+ /** Names of the configured providers. */
25
+ list(): string[] {
26
+ return Object.keys(this.config.providers)
27
+ }
28
+
29
+ private get(name: string): { cfg: OAuthProviderConfig; def: OAuthProviderDefinition } {
30
+ const cfg = this.config.providers[name]
31
+ if (!cfg) throw new Error(`Unknown OAuth provider: ${name}`)
32
+ return { cfg, def: resolveDefinition(cfg) }
33
+ }
34
+
35
+ /** Generate a random `state` value to protect against CSRF. Persist it in the session. */
36
+ createState(): string {
37
+ return randomBytes(16).toString('hex')
38
+ }
39
+
40
+ /** Build the URL to redirect the user to in order to begin the flow. */
41
+ authorizationUrl(name: string, options?: { state?: string; extraParams?: Record<string, string> }): string {
42
+ const { cfg, def } = this.get(name)
43
+ const params = new URLSearchParams({
44
+ response_type: 'code',
45
+ client_id: cfg.clientId,
46
+ redirect_uri: cfg.redirectUri,
47
+ scope: (cfg.scopes ?? def.defaultScopes).join(' '),
48
+ ...(options?.state ? { state: options.state } : {}),
49
+ ...options?.extraParams,
50
+ })
51
+ return `${def.authorizationUrl}?${params}`
52
+ }
53
+
54
+ /** Exchange the authorization `code` returned to your callback for tokens. */
55
+ async exchangeCode(name: string, code: string): Promise<OAuthTokens> {
56
+ const { cfg, def } = this.get(name)
57
+ const res = await fetch(def.tokenUrl, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/x-www-form-urlencoded',
61
+ Accept: 'application/json',
62
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
63
+ 'User-Agent': 'nixxie-cms-oauth',
64
+ },
65
+ body: new URLSearchParams({
66
+ grant_type: 'authorization_code',
67
+ code,
68
+ client_id: cfg.clientId,
69
+ client_secret: cfg.clientSecret,
70
+ redirect_uri: cfg.redirectUri,
71
+ }),
72
+ })
73
+ if (!res.ok) throw new Error(`OAuth token exchange failed (${res.status}): ${await res.text()}`)
74
+ const data: any = await res.json()
75
+ if (data.error) throw new Error(`OAuth token exchange error: ${data.error_description ?? data.error}`)
76
+ return {
77
+ accessToken: data.access_token,
78
+ refreshToken: data.refresh_token,
79
+ expiresIn: data.expires_in,
80
+ tokenType: data.token_type,
81
+ scope: data.scope,
82
+ idToken: data.id_token,
83
+ }
84
+ }
85
+
86
+ /** Fetch the normalised user profile using an access token. */
87
+ async fetchProfile(name: string, tokens: OAuthTokens): Promise<OAuthProfile> {
88
+ const { def } = this.get(name)
89
+ const res = await fetch(def.userInfoUrl, {
90
+ headers: {
91
+ Authorization: `Bearer ${tokens.accessToken}`,
92
+ Accept: 'application/json',
93
+ // GitHub's API rejects requests without a User-Agent (HTTP 403); harmless for others.
94
+ 'User-Agent': 'nixxie-cms-oauth',
95
+ },
96
+ })
97
+ if (!res.ok) throw new Error(`OAuth userinfo failed (${res.status}): ${await res.text()}`)
98
+ return def.profile(await res.json())
99
+ }
100
+
101
+ /** Convenience: exchange the code and fetch the profile in one call. */
102
+ async callback(name: string, code: string): Promise<{ tokens: OAuthTokens; profile: OAuthProfile }> {
103
+ const tokens = await this.exchangeCode(name, code)
104
+ const profile = await this.fetchProfile(name, tokens)
105
+ return { tokens, profile }
106
+ }
107
+ }
108
+
109
+ export function createOAuth(config: OAuthConfig): OAuth {
110
+ return new OAuth(config)
111
+ }
112
+
113
+ export { builtInProviders as providers }
114
+ export type {
115
+ OAuthConfig,
116
+ OAuthProviderConfig,
117
+ OAuthProviderDefinition,
118
+ OAuthProviderName,
119
+ OAuthProfile,
120
+ OAuthTokens,
121
+ } from './types'
@@ -0,0 +1,84 @@
1
+ import type { OAuthProviderDefinition, OAuthProviderName } from './types'
2
+
3
+ export const providers: Record<OAuthProviderName, OAuthProviderDefinition> = {
4
+ google: {
5
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
6
+ tokenUrl: 'https://oauth2.googleapis.com/token',
7
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
8
+ defaultScopes: ['openid', 'email', 'profile'],
9
+ profile: raw => ({
10
+ id: raw.sub,
11
+ email: raw.email,
12
+ name: raw.name,
13
+ avatarUrl: raw.picture,
14
+ raw,
15
+ }),
16
+ },
17
+ github: {
18
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
19
+ tokenUrl: 'https://github.com/login/oauth/access_token',
20
+ userInfoUrl: 'https://api.github.com/user',
21
+ defaultScopes: ['read:user', 'user:email'],
22
+ profile: raw => ({
23
+ id: String(raw.id),
24
+ email: raw.email,
25
+ name: raw.name ?? raw.login,
26
+ avatarUrl: raw.avatar_url,
27
+ raw,
28
+ }),
29
+ },
30
+ gitlab: {
31
+ authorizationUrl: 'https://gitlab.com/oauth/authorize',
32
+ tokenUrl: 'https://gitlab.com/oauth/token',
33
+ userInfoUrl: 'https://gitlab.com/api/v4/user',
34
+ defaultScopes: ['read_user'],
35
+ profile: raw => ({
36
+ id: String(raw.id),
37
+ email: raw.email,
38
+ name: raw.name ?? raw.username,
39
+ avatarUrl: raw.avatar_url,
40
+ raw,
41
+ }),
42
+ },
43
+ discord: {
44
+ authorizationUrl: 'https://discord.com/oauth2/authorize',
45
+ tokenUrl: 'https://discord.com/api/oauth2/token',
46
+ userInfoUrl: 'https://discord.com/api/users/@me',
47
+ defaultScopes: ['identify', 'email'],
48
+ profile: raw => ({
49
+ id: raw.id,
50
+ email: raw.email,
51
+ name: raw.global_name ?? raw.username,
52
+ avatarUrl: raw.avatar
53
+ ? `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png`
54
+ : undefined,
55
+ raw,
56
+ }),
57
+ },
58
+ microsoft: {
59
+ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
60
+ tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
61
+ userInfoUrl: 'https://graph.microsoft.com/oidc/userinfo',
62
+ defaultScopes: ['openid', 'email', 'profile'],
63
+ profile: raw => ({
64
+ id: raw.sub,
65
+ email: raw.email,
66
+ name: raw.name,
67
+ avatarUrl: raw.picture,
68
+ raw,
69
+ }),
70
+ },
71
+ facebook: {
72
+ authorizationUrl: 'https://www.facebook.com/v19.0/dialog/oauth',
73
+ tokenUrl: 'https://graph.facebook.com/v19.0/oauth/access_token',
74
+ userInfoUrl: 'https://graph.facebook.com/me?fields=id,name,email,picture',
75
+ defaultScopes: ['email', 'public_profile'],
76
+ profile: raw => ({
77
+ id: raw.id,
78
+ email: raw.email,
79
+ name: raw.name,
80
+ avatarUrl: raw.picture?.data?.url,
81
+ raw,
82
+ }),
83
+ },
84
+ }
package/src/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ export type OAuthProviderName =
2
+ | 'google'
3
+ | 'github'
4
+ | 'gitlab'
5
+ | 'discord'
6
+ | 'microsoft'
7
+ | 'facebook'
8
+
9
+ /** Endpoints + behaviour for an OAuth 2.0 / OpenID Connect provider. */
10
+ export type OAuthProviderDefinition = {
11
+ /** Authorization endpoint (where the user is redirected to consent). */
12
+ authorizationUrl: string
13
+ /** Token endpoint (where the auth code is exchanged for tokens). */
14
+ tokenUrl: string
15
+ /** Userinfo endpoint used to fetch the profile. */
16
+ userInfoUrl: string
17
+ /** Default scopes requested when the caller doesn't override them. */
18
+ defaultScopes: string[]
19
+ /** Normalise the raw userinfo response into a common profile shape. */
20
+ profile: (raw: any) => OAuthProfile
21
+ }
22
+
23
+ export type OAuthProfile = {
24
+ /** Stable provider-specific user id. */
25
+ id: string
26
+ email?: string
27
+ name?: string
28
+ avatarUrl?: string
29
+ /** The untouched provider response. */
30
+ raw: any
31
+ }
32
+
33
+ export type OAuthTokens = {
34
+ accessToken: string
35
+ refreshToken?: string
36
+ expiresIn?: number
37
+ tokenType?: string
38
+ scope?: string
39
+ idToken?: string
40
+ }
41
+
42
+ export type OAuthProviderConfig = {
43
+ /** Built-in provider name, or omit and supply `definition` for a custom provider. */
44
+ provider?: OAuthProviderName
45
+ /** Custom provider definition (overrides `provider`). */
46
+ definition?: OAuthProviderDefinition
47
+ clientId: string
48
+ clientSecret: string
49
+ /** Redirect/callback URL registered with the provider. */
50
+ redirectUri: string
51
+ /** Override the provider's default scopes. */
52
+ scopes?: string[]
53
+ }
54
+
55
+ export type OAuthConfig = {
56
+ /** Map of named providers, keyed by however you want to reference them in routes. */
57
+ providers: Record<string, OAuthProviderConfig>
58
+ }