kerliix-oauth 1.0.3 → 2.0.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/lib/client.js ADDED
@@ -0,0 +1,214 @@
1
+ import axios from "axios";
2
+ import { generatePKCE } from "./pkce.js";
3
+ import { createVerifier } from "./jwks.js";
4
+ import { generateState, generateNonce } from "./utils.js";
5
+ import {
6
+ exchangeCode,
7
+ refreshAccessToken,
8
+ introspectToken,
9
+ revokeToken,
10
+ clientCredentialsGrant,
11
+ } from "./token.js";
12
+
13
+ export class KerliixClient {
14
+ /**
15
+ * @param {object} config
16
+ * @param {string} config.clientId
17
+ * @param {string} [config.clientSecret] - omit for public clients
18
+ * @param {string} config.redirectUri
19
+ * @param {string} [config.issuer] - accounts-center base URL, default https://accounts.kerliix.com
20
+ * @param {string} [config.scope] - default scope, default "openid profile email"
21
+ */
22
+ constructor({
23
+ clientId,
24
+ clientSecret,
25
+ redirectUri,
26
+ issuer = "https://accounts.kerliix.com",
27
+ scope = "openid profile email",
28
+ }) {
29
+ this.clientId = clientId;
30
+ this.clientSecret = clientSecret || null;
31
+ this.redirectUri = redirectUri;
32
+ this.issuer = issuer.replace(/\/$/, "");
33
+ this.defaultScope = scope;
34
+ this._endpoints = null;
35
+ this._verify = null;
36
+ }
37
+
38
+ // ─── Discovery ──────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Fetch the OIDC discovery document and cache it.
42
+ * The discovery URL is <issuer>/oauth/.well-known/openid-configuration.
43
+ */
44
+ async init() {
45
+ if (this._endpoints) return;
46
+ const discoveryUrl = `${this.issuer}/oauth/.well-known/openid-configuration`;
47
+ const res = await axios.get(discoveryUrl);
48
+ this._endpoints = res.data;
49
+ this._verify = createVerifier(this._endpoints.jwks_uri, this._endpoints.issuer);
50
+ }
51
+
52
+ // ─── Authorization ──────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Build an authorization URL and return it alongside the PKCE verifier, state,
56
+ * and nonce that MUST be persisted in the user session before redirecting.
57
+ *
58
+ * @param {object} [opts]
59
+ * @param {string} [opts.scope] - overrides the default scope
60
+ * @param {string} [opts.state] - supply your own; one is auto-generated if omitted
61
+ * @param {string} [opts.nonce] - supply your own; one is auto-generated if omitted
62
+ * @returns {{ url: string, codeVerifier: string, state: string, nonce: string }}
63
+ */
64
+ async getLoginUrl({ scope, state, nonce } = {}) {
65
+ await this.init();
66
+ const pkce = generatePKCE();
67
+ const usedState = state ?? generateState();
68
+ const usedNonce = nonce ?? generateNonce();
69
+
70
+ const params = new URLSearchParams({
71
+ client_id: this.clientId,
72
+ redirect_uri: this.redirectUri,
73
+ response_type: "code",
74
+ scope: scope ?? this.defaultScope,
75
+ code_challenge: pkce.challenge,
76
+ code_challenge_method: "S256",
77
+ state: usedState,
78
+ nonce: usedNonce,
79
+ });
80
+
81
+ return {
82
+ url: `${this._endpoints.authorization_endpoint}?${params.toString()}`,
83
+ codeVerifier: pkce.verifier,
84
+ state: usedState,
85
+ nonce: usedNonce,
86
+ };
87
+ }
88
+
89
+ // ─── Callback ───────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Exchange the authorization code for tokens and verify the ID token.
93
+ *
94
+ * @param {string} code - `code` query param from the callback URL
95
+ * @param {string} codeVerifier - PKCE verifier from getLoginUrl()
96
+ * @param {object} [opts]
97
+ * @param {string} [opts.expectedNonce] - nonce from getLoginUrl(); verified if supplied
98
+ * @returns {{ tokens: object, user: object|null }}
99
+ */
100
+ async handleCallback(code, codeVerifier, { expectedNonce } = {}) {
101
+ await this.init();
102
+
103
+ const tokens = await exchangeCode({
104
+ code,
105
+ clientId: this.clientId,
106
+ clientSecret: this.clientSecret,
107
+ redirectUri: this.redirectUri,
108
+ codeVerifier,
109
+ tokenEndpoint: this._endpoints.token_endpoint,
110
+ });
111
+
112
+ let user = null;
113
+ if (tokens.id_token) {
114
+ user = await this._verify(tokens.id_token, this.clientId, expectedNonce);
115
+ }
116
+
117
+ return { tokens, user };
118
+ }
119
+
120
+ // ─── Token management ───────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Refresh an access token using a refresh token.
124
+ * @param {string} token - the refresh_token
125
+ */
126
+ async refresh(token) {
127
+ await this.init();
128
+ return refreshAccessToken({
129
+ refreshToken: token,
130
+ clientId: this.clientId,
131
+ clientSecret: this.clientSecret,
132
+ tokenEndpoint: this._endpoints.token_endpoint,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Fetch the UserInfo claims for an access token.
138
+ * @param {string} accessToken
139
+ */
140
+ async getUser(accessToken) {
141
+ await this.init();
142
+ const res = await axios.get(this._endpoints.userinfo_endpoint, {
143
+ headers: { Authorization: `Bearer ${accessToken}` },
144
+ });
145
+ return res.data;
146
+ }
147
+
148
+ /**
149
+ * Introspect a token (RFC 7662). Requires client credentials.
150
+ * @param {string} token - access_token or refresh_token to introspect
151
+ */
152
+ async introspect(token) {
153
+ await this.init();
154
+ return introspectToken({
155
+ token,
156
+ clientId: this.clientId,
157
+ clientSecret: this.clientSecret,
158
+ introspectEndpoint: this._endpoints.introspection_endpoint,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Revoke a token (RFC 7009).
164
+ * @param {string} token - access_token or refresh_token to revoke
165
+ */
166
+ async revoke(token) {
167
+ await this.init();
168
+ await revokeToken({
169
+ token,
170
+ clientId: this.clientId,
171
+ clientSecret: this.clientSecret,
172
+ revokeEndpoint: this._endpoints.revocation_endpoint,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Obtain an access token using the client_credentials grant.
178
+ * Only available for confidential clients.
179
+ * @param {object} [opts]
180
+ * @param {string} [opts.scope]
181
+ */
182
+ async clientCredentials({ scope } = {}) {
183
+ await this.init();
184
+ return clientCredentialsGrant({
185
+ clientId: this.clientId,
186
+ clientSecret: this.clientSecret,
187
+ scope: scope ?? this.defaultScope,
188
+ tokenEndpoint: this._endpoints.token_endpoint,
189
+ });
190
+ }
191
+
192
+ // ─── Logout ─────────────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Build an RP-initiated logout URL (OIDC Session Management).
196
+ * Redirect the user's browser to this URL to end their session.
197
+ *
198
+ * @param {object} [opts]
199
+ * @param {string} [opts.idTokenHint] - ID token from the session
200
+ * @param {string} [opts.postLogoutRedirectUri] - where to send the user after logout
201
+ * @param {string} [opts.state]
202
+ */
203
+ async getLogoutUrl({ idTokenHint, postLogoutRedirectUri, state } = {}) {
204
+ await this.init();
205
+ const base = this._endpoints.end_session_endpoint;
206
+ if (!base) throw new Error("end_session_endpoint not advertised by this server.");
207
+ const params = new URLSearchParams();
208
+ if (idTokenHint) params.set("id_token_hint", idTokenHint);
209
+ if (postLogoutRedirectUri) params.set("post_logout_redirect_uri", postLogoutRedirectUri);
210
+ if (state) params.set("state", state);
211
+ const qs = params.toString();
212
+ return qs ? `${base}?${qs}` : base;
213
+ }
214
+ }
package/lib/jwks.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+
3
+ // Cache JWKS key sets by URI so we don't refetch on every token verification
4
+ const _jwksCache = new Map();
5
+
6
+ /**
7
+ * Create a JWT verifier for RS256 ID tokens.
8
+ *
9
+ * @param {string} jwksUri - from the OIDC discovery document (jwks_uri)
10
+ * @param {string} issuer - from the OIDC discovery document (issuer)
11
+ * @returns {(idToken: string, audience: string, nonce?: string) => Promise<object>}
12
+ */
13
+ export function createVerifier(jwksUri, issuer) {
14
+ if (!_jwksCache.has(jwksUri)) {
15
+ _jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
16
+ }
17
+ const jwks = _jwksCache.get(jwksUri);
18
+
19
+ return async function verify(idToken, audience, nonce) {
20
+ const { payload } = await jwtVerify(idToken, jwks, {
21
+ issuer,
22
+ audience,
23
+ algorithms: ["RS256"],
24
+ });
25
+
26
+ if (nonce !== undefined && payload.nonce !== nonce) {
27
+ throw new Error(
28
+ `Nonce mismatch: expected "${nonce}", got "${payload.nonce}"`
29
+ );
30
+ }
31
+
32
+ return payload;
33
+ };
34
+ }
@@ -0,0 +1,104 @@
1
+ import express from "express";
2
+
3
+ /**
4
+ * `protect(client)` — Express middleware that enforces a valid Bearer token.
5
+ *
6
+ * Calls client.getUser(accessToken) against the userinfo endpoint.
7
+ * On success, attaches the claims to req.user and calls next().
8
+ * On failure, responds 401.
9
+ *
10
+ * @param {import("./client.js").KerliixClient} client
11
+ */
12
+ export function protect(client) {
13
+ return async (req, res, next) => {
14
+ const auth = req.headers.authorization;
15
+ if (!auth || !auth.startsWith("Bearer ")) {
16
+ return res.status(401).json({
17
+ error: "unauthorized",
18
+ error_description: "Bearer token required.",
19
+ });
20
+ }
21
+ try {
22
+ const token = auth.slice(7);
23
+ req.user = await client.getUser(token);
24
+ next();
25
+ } catch {
26
+ res.status(401).json({
27
+ error: "unauthorized",
28
+ error_description: "Invalid or expired token.",
29
+ });
30
+ }
31
+ };
32
+ }
33
+
34
+ /**
35
+ * `routes(client)` — Mounts pre-built login and callback routes on an Express router.
36
+ *
37
+ * Requires `express-session` (or compatible session middleware) to be set up
38
+ * before these routes so that PKCE state can be persisted across the redirect.
39
+ *
40
+ * Mounted routes:
41
+ * GET /login — redirect user to Kerliix accounts
42
+ * GET /callback — exchange code, store tokens in session, call next()
43
+ *
44
+ * After /callback succeeds, req.oauthResult contains { tokens, user }.
45
+ * Add your own next() handler after this router to redirect or respond.
46
+ *
47
+ * @param {import("./client.js").KerliixClient} client
48
+ */
49
+ export function routes(client) {
50
+ const router = express.Router();
51
+
52
+ router.get("/login", async (req, res, next) => {
53
+ try {
54
+ const { url, codeVerifier, state, nonce } = await client.getLoginUrl();
55
+ // Persist PKCE and CSRF state in session before redirecting
56
+ req.session = req.session ?? {};
57
+ req.session.oauthCodeVerifier = codeVerifier;
58
+ req.session.oauthState = state;
59
+ req.session.oauthNonce = nonce;
60
+ res.redirect(url);
61
+ } catch (err) {
62
+ next(err);
63
+ }
64
+ });
65
+
66
+ router.get("/callback", async (req, res, next) => {
67
+ try {
68
+ const { code, state, error, error_description } = req.query;
69
+
70
+ if (error) {
71
+ return res.status(400).json({ error, error_description });
72
+ }
73
+
74
+ const session = req.session ?? {};
75
+
76
+ // Validate state to prevent CSRF
77
+ if (session.oauthState && state !== session.oauthState) {
78
+ return res.status(400).json({
79
+ error: "invalid_state",
80
+ error_description: "State parameter mismatch.",
81
+ });
82
+ }
83
+
84
+ const result = await client.handleCallback(
85
+ code,
86
+ session.oauthCodeVerifier,
87
+ { expectedNonce: session.oauthNonce }
88
+ );
89
+
90
+ // Clean up PKCE state from session
91
+ delete session.oauthCodeVerifier;
92
+ delete session.oauthState;
93
+ delete session.oauthNonce;
94
+
95
+ // Make result available to the next handler
96
+ req.oauthResult = result;
97
+ next();
98
+ } catch (err) {
99
+ next(err);
100
+ }
101
+ });
102
+
103
+ return router;
104
+ }
package/lib/pkce.js ADDED
@@ -0,0 +1,16 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * Generate a PKCE (Proof Key for Code Exchange) pair.
5
+ * Returns a random `verifier` and its S256 `challenge`.
6
+ *
7
+ * RFC 7636 §4.1 — verifier must be 43-128 characters of [A-Z a-z 0-9 - . _ ~]
8
+ */
9
+ export function generatePKCE() {
10
+ const verifier = crypto.randomBytes(48).toString("base64url");
11
+ const challenge = crypto
12
+ .createHash("sha256")
13
+ .update(verifier)
14
+ .digest("base64url");
15
+ return { verifier, challenge };
16
+ }
package/lib/token.js ADDED
@@ -0,0 +1,107 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * Build an HTTP Basic Authorization header from clientId and clientSecret.
5
+ * Percent-encodes each value per RFC 6749 §2.3.1.
6
+ */
7
+ function basicAuth(clientId, clientSecret) {
8
+ const encoded = Buffer.from(
9
+ `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`
10
+ ).toString("base64");
11
+ return `Basic ${encoded}`;
12
+ }
13
+
14
+ /**
15
+ * POST to a token endpoint with application/x-www-form-urlencoded body.
16
+ * Uses client_secret_basic (Authorization header) when a clientSecret is present,
17
+ * falling back to including client_id in the body for public clients.
18
+ */
19
+ async function postToken(endpoint, params, clientId, clientSecret) {
20
+ const body = new URLSearchParams(params);
21
+ const headers = { "Content-Type": "application/x-www-form-urlencoded" };
22
+
23
+ if (clientSecret) {
24
+ headers.Authorization = basicAuth(clientId, clientSecret);
25
+ } else {
26
+ body.set("client_id", clientId);
27
+ }
28
+
29
+ const res = await axios.post(endpoint, body.toString(), { headers });
30
+ return res.data;
31
+ }
32
+
33
+ // ─── Grant handlers ──────────────────────────────────────────────────────────
34
+
35
+ export async function exchangeCode({
36
+ code,
37
+ clientId,
38
+ clientSecret,
39
+ redirectUri,
40
+ codeVerifier,
41
+ tokenEndpoint,
42
+ }) {
43
+ const params = {
44
+ grant_type: "authorization_code",
45
+ code,
46
+ redirect_uri: redirectUri,
47
+ ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
48
+ };
49
+ return postToken(tokenEndpoint, params, clientId, clientSecret);
50
+ }
51
+
52
+ export async function refreshAccessToken({
53
+ refreshToken,
54
+ clientId,
55
+ clientSecret,
56
+ tokenEndpoint,
57
+ }) {
58
+ return postToken(
59
+ tokenEndpoint,
60
+ { grant_type: "refresh_token", refresh_token: refreshToken },
61
+ clientId,
62
+ clientSecret
63
+ );
64
+ }
65
+
66
+ export async function introspectToken({
67
+ token,
68
+ clientId,
69
+ clientSecret,
70
+ introspectEndpoint,
71
+ }) {
72
+ const body = new URLSearchParams({ token });
73
+ const headers = {
74
+ "Content-Type": "application/x-www-form-urlencoded",
75
+ Authorization: basicAuth(clientId, clientSecret),
76
+ };
77
+ const res = await axios.post(introspectEndpoint, body.toString(), { headers });
78
+ return res.data;
79
+ }
80
+
81
+ export async function revokeToken({
82
+ token,
83
+ clientId,
84
+ clientSecret,
85
+ revokeEndpoint,
86
+ }) {
87
+ const body = new URLSearchParams({ token });
88
+ const headers = {
89
+ "Content-Type": "application/x-www-form-urlencoded",
90
+ Authorization: basicAuth(clientId, clientSecret),
91
+ };
92
+ await axios.post(revokeEndpoint, body.toString(), { headers });
93
+ }
94
+
95
+ export async function clientCredentialsGrant({
96
+ clientId,
97
+ clientSecret,
98
+ scope,
99
+ tokenEndpoint,
100
+ }) {
101
+ return postToken(
102
+ tokenEndpoint,
103
+ { grant_type: "client_credentials", scope },
104
+ clientId,
105
+ clientSecret
106
+ );
107
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,17 @@
1
+ import crypto from "crypto";
2
+
3
+ export function buildQueryString(params) {
4
+ return Object.entries(params)
5
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
6
+ .join("&");
7
+ }
8
+
9
+ /** Generate a cryptographically random state value (hex). */
10
+ export function generateState() {
11
+ return crypto.randomBytes(16).toString("hex");
12
+ }
13
+
14
+ /** Generate a cryptographically random nonce value (hex). */
15
+ export function generateNonce() {
16
+ return crypto.randomBytes(16).toString("hex");
17
+ }
package/package.json CHANGED
@@ -1,31 +1,16 @@
1
- {
2
- "name": "kerliix-oauth",
3
- "version": "1.0.3",
4
- "description": "Official Kerliix OAuth SDK for Node.js and browser",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "type": "module",
8
- "scripts": {
9
- "build": "tsc",
10
- "prepublishOnly": "npm run build",
11
- "test": "node tests/client.test.js"
12
- },
13
- "keywords": [
14
- "oauth2",
15
- "kerliix",
16
- "nodejs",
17
- "sdk",
18
- "authentication",
19
- "typescript",
20
- "oauth"
21
- ],
22
- "author": "Kerliix Corporation",
23
- "license": "MIT",
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/kerliix-corp/kerliix-oauth-nodejs.git"
27
- },
28
- "devDependencies": {
29
- "typescript": "^5.6.3"
30
- }
31
- }
1
+ {
2
+ "name": "kerliix-oauth",
3
+ "version": "2.0.0",
4
+ "description": "Official Kerliix OAuth 2.0 / OIDC SDK for Node.js",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "keywords": ["oauth", "openid", "oidc", "pkce", "kerliix", "authentication", "sdk"],
8
+ "author": "Kerliix Corporation Limited",
9
+ "license": "Apache-2.0",
10
+ "engines": { "node": ">=18" },
11
+ "dependencies": {
12
+ "axios": "^1.6.0",
13
+ "express": "^4.18.2",
14
+ "jose": "^5.0.0"
15
+ }
16
+ }
@@ -1,35 +0,0 @@
1
- name: Build
2
-
3
- # Run workflow on pushes and pull requests to main
4
- on:
5
- push:
6
- branches: [ main ]
7
- pull_request:
8
- branches: [ main ]
9
-
10
- jobs:
11
- build:
12
- name: Build and Test
13
- runs-on: ubuntu-latest
14
-
15
- strategy:
16
- matrix:
17
- node-version: [18, 20] # Test against Node 18 and 20
18
-
19
- steps:
20
- - name: Checkout repository
21
- uses: actions/checkout@v3
22
-
23
- - name: Setup Node.js
24
- uses: actions/setup-node@v3
25
- with:
26
- node-version: ${{ matrix.node-version }}
27
-
28
- - name: Install dependencies
29
- run: npm install
30
-
31
- - name: Build TypeScript
32
- run: npm run build
33
-
34
- - name: Run tests
35
- run: npm test