@tensorgrad/oauth 0.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 ADDED
@@ -0,0 +1,154 @@
1
+ # @tensorgrad/oauth
2
+
3
+ Small OAuth client SDK for integrating tensorGrad SSO into first-party apps.
4
+
5
+ ## What it does
6
+
7
+ - creates `state` values
8
+ - creates PKCE verifier/challenge pairs
9
+ - builds tensorGrad `/oauth/authorize` URLs
10
+ - exchanges authorization codes at `/oauth/token`
11
+ - fetches canonical identity from `/oauth/userinfo`
12
+ - normalizes tensorGrad user payloads
13
+ - validates granted scopes
14
+
15
+ ## What it does not do
16
+
17
+ - manage your app sessions or cookies
18
+ - manage roles or permissions inside your app
19
+ - assume a frontend framework
20
+
21
+ ## Runtime
22
+
23
+ This package targets Web API runtimes such as:
24
+
25
+ - Cloudflare Workers
26
+ - modern browsers
27
+ - Node runtimes that expose standard Web APIs
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install @tensorgrad/oauth
33
+ ```
34
+
35
+ ## Core constants
36
+
37
+ ```ts
38
+ import { TENSORGRAD_ISSUER } from "@tensorgrad/oauth";
39
+
40
+ console.log(TENSORGRAD_ISSUER);
41
+ // "https://www.tensorgrad.com"
42
+ ```
43
+
44
+ ## Full Cloudflare Worker example
45
+
46
+ ```ts
47
+ import {
48
+ TENSORGRAD_ISSUER,
49
+ assertGrantedScopes,
50
+ createAuthorizationUrl,
51
+ createPkcePair,
52
+ createState,
53
+ exchangeAuthorizationCode,
54
+ fetchUserInfo,
55
+ isAdminScopeGranted,
56
+ normalizeTgUser
57
+ } from "@tensorgrad/oauth";
58
+
59
+ export interface Env {
60
+ TENSORGRAD_CLIENT_ID: string;
61
+ TENSORGRAD_CLIENT_SECRET: string;
62
+ }
63
+
64
+ function html(body: string, status = 200): Response {
65
+ return new Response(body, {
66
+ status,
67
+ headers: { "Content-Type": "text/html; charset=utf-8" }
68
+ });
69
+ }
70
+
71
+ export default {
72
+ async fetch(request: Request, env: Env): Promise<Response> {
73
+ const url = new URL(request.url);
74
+
75
+ if (url.pathname === "/login") {
76
+ const state = createState();
77
+ const pkce = await createPkcePair();
78
+ const redirectUri = `${url.origin}/auth/callback`;
79
+
80
+ const authorizeUrl = createAuthorizationUrl(
81
+ {
82
+ issuer: TENSORGRAD_ISSUER,
83
+ clientId: env.TENSORGRAD_CLIENT_ID
84
+ },
85
+ {
86
+ redirectUri,
87
+ scope: ["openid", "profile", "email"],
88
+ state,
89
+ codeChallenge: pkce.codeChallenge,
90
+ codeChallengeMethod: pkce.codeChallengeMethod
91
+ }
92
+ );
93
+
94
+ return html(`
95
+ <p><a href="${authorizeUrl.toString()}">Continue with tensorGrad</a></p>
96
+ <p>Persist state and codeVerifier in your own storage before redirecting.</p>
97
+ <pre>${JSON.stringify({ state, codeVerifier: pkce.codeVerifier }, null, 2)}</pre>
98
+ `);
99
+ }
100
+
101
+ if (url.pathname === "/auth/callback") {
102
+ const code = url.searchParams.get("code");
103
+ const state = url.searchParams.get("state");
104
+
105
+ if (!code || !state) {
106
+ return new Response("Missing code or state", { status: 400 });
107
+ }
108
+
109
+ const redirectUri = `${url.origin}/auth/callback`;
110
+
111
+ // Replace these with values loaded from your own storage.
112
+ const expectedState = "<persisted-state>";
113
+ const codeVerifier = "<persisted-code-verifier>";
114
+
115
+ if (state !== expectedState) {
116
+ return new Response("Invalid state", { status: 400 });
117
+ }
118
+
119
+ const token = await exchangeAuthorizationCode({
120
+ issuer: TENSORGRAD_ISSUER,
121
+ clientId: env.TENSORGRAD_CLIENT_ID,
122
+ clientSecret: env.TENSORGRAD_CLIENT_SECRET,
123
+ code,
124
+ redirectUri,
125
+ codeVerifier
126
+ });
127
+
128
+ assertGrantedScopes(token.scope, ["openid", "profile", "email"]);
129
+
130
+ const userFromToken = normalizeTgUser(token.user);
131
+ const userFromUserInfo = normalizeTgUser(
132
+ await fetchUserInfo(TENSORGRAD_ISSUER, token.access_token)
133
+ );
134
+
135
+ return Response.json({
136
+ token,
137
+ userFromToken,
138
+ userFromUserInfo,
139
+ adminGranted: isAdminScopeGranted(token.scope)
140
+ });
141
+ }
142
+
143
+ return new Response("Not found", { status: 404 });
144
+ }
145
+ };
146
+ ```
147
+
148
+ ## Notes
149
+
150
+ - `openid` is mandatory and the library enforces that when building authorize URLs.
151
+ - tensorGrad currently uses `https://www.tensorgrad.com` as the OAuth provider.
152
+ - `assertGrantedScopes(token.scope, ["admin"])` is the simplest way to require admin access.
153
+ - `isAdminScopeGranted(token.scope)` is available when you only need a boolean check.
154
+ - app session and logout handling stay in the consuming app.
@@ -0,0 +1,57 @@
1
+ export declare const TENSORGRAD_ISSUER = "https://www.tensorgrad.com";
2
+ export interface tgOAuthConfig {
3
+ issuer: string;
4
+ clientId: string;
5
+ clientSecret?: string;
6
+ }
7
+ export interface PkcePair {
8
+ codeVerifier: string;
9
+ codeChallenge: string;
10
+ codeChallengeMethod: "S256";
11
+ }
12
+ export interface AuthorizationUrlOptions {
13
+ redirectUri: string;
14
+ scope: string[] | string;
15
+ state: string;
16
+ codeChallenge?: string;
17
+ codeChallengeMethod?: "S256" | "plain";
18
+ extraParams?: Record<string, string>;
19
+ }
20
+ export interface TokenExchangeOptions {
21
+ issuer: string;
22
+ clientId: string;
23
+ clientSecret?: string;
24
+ code: string;
25
+ redirectUri: string;
26
+ codeVerifier?: string;
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+ export interface tgTokenResponse {
30
+ access_token: string;
31
+ token_type: string;
32
+ expires_in: number;
33
+ scope: string;
34
+ user: tgUserInfo;
35
+ }
36
+ export interface tgUserInfo {
37
+ sub: string;
38
+ email: string;
39
+ email_verified: boolean;
40
+ name: string | null;
41
+ image: string | null;
42
+ }
43
+ export interface normalizedTgUser {
44
+ id: string;
45
+ email: string;
46
+ emailVerified: boolean;
47
+ name: string | null;
48
+ image: string | null;
49
+ }
50
+ export declare function createState(length?: number): string;
51
+ export declare function createPkcePair(length?: number): Promise<PkcePair>;
52
+ export declare function createAuthorizationUrl(config: tgOAuthConfig, options: AuthorizationUrlOptions): URL;
53
+ export declare function exchangeAuthorizationCode(options: TokenExchangeOptions): Promise<tgTokenResponse>;
54
+ export declare function fetchUserInfo(issuer: string, accessToken: string, fetchImpl?: typeof fetch): Promise<tgUserInfo>;
55
+ export declare function normalizeTgUser(user: tgUserInfo): normalizedTgUser;
56
+ export declare function assertGrantedScopes(grantedScope: string[] | string, requiredScopes: readonly string[]): void;
57
+ export declare function isAdminScopeGranted(grantedScope: string[] | string): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,174 @@
1
+ export const TENSORGRAD_ISSUER = "https://www.tensorgrad.com";
2
+ const MIN_STATE_BYTES = 16;
3
+ const MAX_RANDOM_BYTES = 96;
4
+ const MIN_PKCE_RANDOM_BYTES = 32;
5
+ const RESERVED_AUTHORIZATION_PARAMS = new Set([
6
+ "client_id",
7
+ "redirect_uri",
8
+ "response_type",
9
+ "scope",
10
+ "state",
11
+ "code_challenge",
12
+ "code_challenge_method"
13
+ ]);
14
+ function requireNonEmptyString(name, value) {
15
+ const trimmed = value.trim();
16
+ if (!trimmed) {
17
+ throw new Error(`${name} must be a non-empty string`);
18
+ }
19
+ return trimmed;
20
+ }
21
+ function assertRandomByteLength(name, length, min, max = MAX_RANDOM_BYTES) {
22
+ if (!Number.isInteger(length) || length < min || length > max) {
23
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
24
+ }
25
+ }
26
+ function normalizeIssuer(issuer) {
27
+ const normalized = requireNonEmptyString("issuer", issuer).replace(/\/+$/, "");
28
+ const parsed = new URL(normalized);
29
+ if (!parsed.protocol || !parsed.host) {
30
+ throw new Error("issuer must be an absolute URL");
31
+ }
32
+ return parsed.toString().replace(/\/+$/, "");
33
+ }
34
+ function normalizeAbsoluteUrl(name, value) {
35
+ const normalized = requireNonEmptyString(name, value);
36
+ return new URL(normalized).toString();
37
+ }
38
+ function parseScopeSet(scope) {
39
+ return new Set((Array.isArray(scope) ? scope : scope.split(/\s+/))
40
+ .map((value) => value.trim())
41
+ .filter(Boolean));
42
+ }
43
+ function normalizeScopeString(scope) {
44
+ const values = Array.from(parseScopeSet(scope));
45
+ if (values.length === 0) {
46
+ throw new Error("scope must contain at least one value");
47
+ }
48
+ if (!values.includes("openid")) {
49
+ throw new Error("scope must include openid");
50
+ }
51
+ return values.join(" ");
52
+ }
53
+ function toBase64Url(bytes) {
54
+ let binary = "";
55
+ for (const byte of bytes) {
56
+ binary += String.fromCharCode(byte);
57
+ }
58
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
59
+ }
60
+ async function sha256(input) {
61
+ const encoded = new TextEncoder().encode(input);
62
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
63
+ return new Uint8Array(digest);
64
+ }
65
+ export function createState(length = 32) {
66
+ assertRandomByteLength("state length", length, MIN_STATE_BYTES);
67
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
68
+ return toBase64Url(bytes);
69
+ }
70
+ export async function createPkcePair(length = 32) {
71
+ assertRandomByteLength("PKCE length", length, MIN_PKCE_RANDOM_BYTES);
72
+ const verifierBytes = crypto.getRandomValues(new Uint8Array(length));
73
+ const codeVerifier = toBase64Url(verifierBytes);
74
+ const codeChallenge = toBase64Url(await sha256(codeVerifier));
75
+ return {
76
+ codeVerifier,
77
+ codeChallenge,
78
+ codeChallengeMethod: "S256"
79
+ };
80
+ }
81
+ export function createAuthorizationUrl(config, options) {
82
+ const redirectUri = normalizeAbsoluteUrl("redirectUri", options.redirectUri);
83
+ const state = requireNonEmptyString("state", options.state);
84
+ const scope = normalizeScopeString(options.scope);
85
+ const clientId = requireNonEmptyString("clientId", config.clientId);
86
+ const url = new URL("/oauth/authorize", normalizeIssuer(config.issuer));
87
+ url.searchParams.set("client_id", clientId);
88
+ url.searchParams.set("redirect_uri", redirectUri);
89
+ url.searchParams.set("response_type", "code");
90
+ url.searchParams.set("scope", scope);
91
+ url.searchParams.set("state", state);
92
+ if (options.codeChallenge) {
93
+ url.searchParams.set("code_challenge", requireNonEmptyString("codeChallenge", options.codeChallenge));
94
+ url.searchParams.set("code_challenge_method", options.codeChallengeMethod ?? "S256");
95
+ }
96
+ for (const [key, value] of Object.entries(options.extraParams ?? {})) {
97
+ if (RESERVED_AUTHORIZATION_PARAMS.has(key)) {
98
+ throw new Error(`extraParams cannot override reserved OAuth parameter: ${key}`);
99
+ }
100
+ url.searchParams.set(key, requireNonEmptyString(`extraParams.${key}`, value));
101
+ }
102
+ return url;
103
+ }
104
+ async function readJsonOrThrow(response) {
105
+ const text = await response.text();
106
+ let payload;
107
+ try {
108
+ payload = text ? JSON.parse(text) : {};
109
+ }
110
+ catch {
111
+ throw new Error(`Unexpected non-JSON response: ${text}`);
112
+ }
113
+ if (!response.ok) {
114
+ const message = typeof payload === "object" &&
115
+ payload !== null &&
116
+ ("error_description" in payload || "error" in payload)
117
+ ? String(payload.error_description ??
118
+ payload.error)
119
+ : `HTTP ${response.status}`;
120
+ throw new Error(message);
121
+ }
122
+ return payload;
123
+ }
124
+ export async function exchangeAuthorizationCode(options) {
125
+ const fetchImpl = options.fetchImpl ?? fetch;
126
+ const url = new URL("/oauth/token", normalizeIssuer(options.issuer));
127
+ const body = new URLSearchParams();
128
+ body.set("grant_type", "authorization_code");
129
+ body.set("client_id", requireNonEmptyString("clientId", options.clientId));
130
+ body.set("code", requireNonEmptyString("code", options.code));
131
+ body.set("redirect_uri", normalizeAbsoluteUrl("redirectUri", options.redirectUri));
132
+ if (options.clientSecret) {
133
+ body.set("client_secret", options.clientSecret);
134
+ }
135
+ if (options.codeVerifier) {
136
+ body.set("code_verifier", requireNonEmptyString("codeVerifier", options.codeVerifier));
137
+ }
138
+ const response = await fetchImpl(url, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/x-www-form-urlencoded"
142
+ },
143
+ body
144
+ });
145
+ return readJsonOrThrow(response);
146
+ }
147
+ export async function fetchUserInfo(issuer, accessToken, fetchImpl = fetch) {
148
+ const url = new URL("/oauth/userinfo", normalizeIssuer(issuer));
149
+ const response = await fetchImpl(url, {
150
+ headers: {
151
+ Authorization: `Bearer ${requireNonEmptyString("accessToken", accessToken)}`
152
+ }
153
+ });
154
+ return readJsonOrThrow(response);
155
+ }
156
+ export function normalizeTgUser(user) {
157
+ return {
158
+ id: user.sub,
159
+ email: user.email,
160
+ emailVerified: user.email_verified,
161
+ name: user.name,
162
+ image: user.image
163
+ };
164
+ }
165
+ export function assertGrantedScopes(grantedScope, requiredScopes) {
166
+ const granted = parseScopeSet(grantedScope);
167
+ const missing = requiredScopes.filter((scope) => !granted.has(scope));
168
+ if (missing.length > 0) {
169
+ throw new Error(`Missing granted scopes: ${missing.join(", ")}`);
170
+ }
171
+ }
172
+ export function isAdminScopeGranted(grantedScope) {
173
+ return parseScopeSet(grantedScope).has("admin");
174
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@tensorgrad/oauth",
3
+ "version": "0.1.0",
4
+ "description": "Small OAuth client SDK for tensorGrad SSO integrations.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "oauth",
26
+ "pkce",
27
+ "tensorgrad",
28
+ "sso"
29
+ ],
30
+ "license": "MIT",
31
+ "devDependencies": {
32
+ "typescript": "^5.9.2"
33
+ }
34
+ }