nocal-auth-sdk 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,325 @@
1
+ # Nocal Auth SDK
2
+
3
+ A lightweight, secure client-side TypeScript/JavaScript SDK for implementing "Login with Nocal" in third-party applications via popup flow.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Popup-based OAuth flow** - Opens authentication in a centered popup window
8
+ - ✅ **PostMessage protocol** - Secure communication between popup and parent window
9
+ - ✅ **Origin verification** - Validates that messages come from trusted domain
10
+ - ✅ **Memory cleanup** - Automatically removes event listeners and closes popup
11
+ - ✅ **Full TypeScript support** - Fully typed for better DX
12
+ - ✅ **CSRF protection** - Support for state parameter validation
13
+ - ✅ **Zero dependencies** - Uses native browser APIs only
14
+
15
+ ## Installation
16
+
17
+ ### From npm (when published):
18
+
19
+ ```bash
20
+ npm install nocal-auth-sdk
21
+ ```
22
+
23
+ ### From local development:
24
+
25
+ ```bash
26
+ npm install /path/to/sdk
27
+ # or
28
+ npm pack
29
+ npm install nocal-auth-sdk-0.1.0.tgz
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ### Basic Usage (Vanilla JavaScript/TypeScript)
35
+
36
+ ```typescript
37
+ import { NocalAuth } from 'nocal-auth-sdk';
38
+
39
+ // Initialize the SDK
40
+ const nocalAuth = new NocalAuth({
41
+ clientId: 'your_client_id',
42
+ redirectUri: 'https://yourapp.example.com/auth/callback',
43
+ scope: 'openid profile email',
44
+ state: 'random_state_value' // CSRF protection
45
+ });
46
+
47
+ // Trigger login in popup
48
+ async function handleLoginClick() {
49
+ const result = await nocalAuth.loginWithPopup({
50
+ width: 520,
51
+ height: 700
52
+ });
53
+
54
+ if (result.type === 'success') {
55
+ console.log('✅ Login successful!', result.data);
56
+ // result.data may contain: { token, id_token, code, user, profile, ... }
57
+ // Handle token storage, redirect, etc.
58
+ } else if (result.type === 'error') {
59
+ console.error('❌ Authentication failed:', result.error);
60
+ // Handle error: popup_blocked, unknown_error, etc.
61
+ } else if (result.type === 'closed') {
62
+ console.warn('⚠️ User closed popup before completing auth');
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### React Example
68
+
69
+ ```jsx
70
+ import { useCallback } from 'react';
71
+ import { NocalAuth } from 'nocal-auth-sdk';
72
+
73
+ export function LoginButton() {
74
+ const handleLogin = useCallback(async () => {
75
+ const sdk = new NocalAuth({
76
+ clientId: process.env.REACT_APP_NOCAL_CLIENT_ID!,
77
+ redirectUri: `${window.location.origin}/auth/callback`,
78
+ scope: 'openid profile email',
79
+ state: generateRandomState() // Use crypto.randomUUID() or similar
80
+ });
81
+
82
+ const result = await sdk.loginWithPopup({ width: 520, height: 700 });
83
+
84
+ switch (result.type) {
85
+ case 'success':
86
+ // Store token
87
+ localStorage.setItem('nocal_token', result.data.token);
88
+ // Redirect or update app state
89
+ window.location.href = '/dashboard';
90
+ break;
91
+
92
+ case 'error':
93
+ alert(`Login failed: ${result.error}`);
94
+ break;
95
+
96
+ case 'closed':
97
+ console.log('User cancelled login');
98
+ break;
99
+ }
100
+ }, []);
101
+
102
+ return (
103
+ <button onClick={handleLogin} className="btn btn-primary">
104
+ 🔐 Login with Nocal
105
+ </button>
106
+ );
107
+ }
108
+
109
+ function generateRandomState() {
110
+ return crypto.randomUUID?.() || Math.random().toString(36).substr(2);
111
+ }
112
+ ```
113
+
114
+ ### Vue 3 Composition API Example
115
+
116
+ ```vue
117
+ <script setup lang="ts">
118
+ import { ref } from 'vue';
119
+ import { NocalAuth } from 'nocal-auth-sdk';
120
+
121
+ const isLoading = ref(false);
122
+ const error = ref('');
123
+
124
+ const handleLogin = async () => {
125
+ isLoading.value = true;
126
+ error.value = '';
127
+
128
+ const sdk = new NocalAuth({
129
+ clientId: import.meta.env.VITE_NOCAL_CLIENT_ID,
130
+ redirectUri: `${window.location.origin}/auth/callback`,
131
+ scope: 'openid profile email'
132
+ });
133
+
134
+ const result = await sdk.loginWithPopup();
135
+
136
+ if (result.type === 'success') {
137
+ // Store token and redirect
138
+ localStorage.setItem('nocal_token', result.data.token);
139
+ window.location.href = '/dashboard';
140
+ } else if (result.type === 'error') {
141
+ error.value = result.error;
142
+ }
143
+
144
+ isLoading.value = false;
145
+ };
146
+ </script>
147
+
148
+ <template>
149
+ <button @click="handleLogin" :disabled="isLoading">
150
+ {{ isLoading ? 'Logging in...' : '🔐 Login with Nocal' }}
151
+ </button>
152
+ <p v-if="error" class="error">{{ error }}</p>
153
+ </template>
154
+ ```
155
+
156
+ ## API Reference
157
+
158
+ ### Constructor
159
+
160
+ ```typescript
161
+ new NocalAuth(options: NocalAuthOptions)
162
+ ```
163
+
164
+ **Options:**
165
+
166
+ | Property | Type | Required | Default | Description |
167
+ |----------|------|----------|---------|-------------|
168
+ | `clientId` | `string` | ✅ | - | OAuth client ID from Nocal Auth |
169
+ | `redirectUri` | `string` | ✅ | - | Callback URL after authentication |
170
+ | `scope` | `string` | ❌ | - | OAuth scopes (e.g., `"openid profile email"`) |
171
+ | `state` | `string` | ❌ | - | CSRF token (you should generate this) |
172
+ | `issuerOrigin` | `string` | ❌ | `https://auth.nocal.dev` | Authorized domain for postMessage |
173
+ | `popupFeatures` | `string` | ❌ | - | Extra window.open features |
174
+ | `extraParams` | `Record<string, string>` | ❌ | - | Additional query parameters |
175
+
176
+ ### Methods
177
+
178
+ #### `loginWithPopup(options?: PopupOptions): Promise<PopupResult<T>>`
179
+
180
+ Opens a centered popup for authentication.
181
+
182
+ **PopupOptions:**
183
+
184
+ ```typescript
185
+ interface PopupOptions {
186
+ width?: number; // Default: 500
187
+ height?: number; // Default: 700
188
+ popupName?: string; // Default: 'nocal_auth_popup'
189
+ }
190
+ ```
191
+
192
+ **PopupResult:**
193
+
194
+ ```typescript
195
+ type PopupResult<T> =
196
+ | { type: 'success'; data: T } // Authentication succeeded
197
+ | { type: 'error'; error: string } // Error occurred
198
+ | { type: 'closed' } // User closed popup
199
+ ```
200
+
201
+ **Returns:** `Promise<PopupResult<Record<string, any>>>`
202
+
203
+ The `data` object typically contains:
204
+ - `token` - Access token
205
+ - `id_token` - OpenID Connect token (if `openid` in scope)
206
+ - `code` - Authorization code (if using code flow)
207
+ - `user` or `profile` - User information
208
+ - Other fields based on your server configuration
209
+
210
+ ## Server-Side Requirements
211
+
212
+ Your Nocal Auth server must:
213
+
214
+ 1. Accept the authorize request at `/authorize?client_id=...&redirect_uri=...`
215
+ 2. After successful authentication, post a message back to the popup's opener:
216
+
217
+ ```javascript
218
+ // In the popup (auth.nocal.dev), after auth completes:
219
+ window.opener.postMessage(
220
+ {
221
+ type: 'nocal_auth',
222
+ success: true,
223
+ data: {
224
+ token: 'access_token_value',
225
+ id_token: 'id_token_value', // optional
226
+ user: { id: '123', email: 'user@example.com' }
227
+ }
228
+ },
229
+ 'https://yourapp.example.com' // client origin
230
+ );
231
+ ```
232
+
233
+ Or on error:
234
+
235
+ ```javascript
236
+ window.opener.postMessage(
237
+ {
238
+ type: 'nocal_auth',
239
+ success: false,
240
+ error: 'invalid_grant' // or other error code
241
+ },
242
+ 'https://yourapp.example.com'
243
+ );
244
+ ```
245
+
246
+ 3. Ensure the origin in `postMessage` matches your client app's origin for browser security
247
+
248
+ ## Security Considerations
249
+
250
+ ### 1. **Origin Verification** ✅
251
+ - SDK automatically verifies `event.origin === issuerOrigin` before accepting messages
252
+ - Prevents clickjacking and XSS attacks from unauthorized domains
253
+
254
+ ### 2. **State Parameter** 🔐
255
+ - Always generate a random `state` value client-side
256
+ - Pass it to the SDK constructor
257
+ - Server should validate it matches before returning auth result
258
+ - Example: `crypto.randomUUID()` or `Math.random().toString(36)`
259
+
260
+ ### 3. **HTTPS Only** 🔒
261
+ - Both client app and Nocal server must use HTTPS in production
262
+ - Prevents man-in-the-middle attacks
263
+
264
+ ### 4. **Token Storage** 💾
265
+ - Store tokens securely:
266
+ - **Recommended:** Session/memory (least persistent, most secure)
267
+ - **Alternative:** localStorage with HttpOnly cookie for sensitive data
268
+ - **Avoid:** localStorage for sensitive tokens without additional encryption
269
+
270
+ ### 5. **PKCE for SPA** (Future Enhancement)
271
+ - For maximum security, implement PKCE (RFC 7636)
272
+ - Protects against authorization code interception
273
+
274
+ ## Building from Source
275
+
276
+ ```bash
277
+ # Install dependencies
278
+ npm install
279
+
280
+ # Build TypeScript to dist/
281
+ npm run build
282
+
283
+ # Output structure:
284
+ # dist/
285
+ # ├── index.d.ts (TypeScript declarations)
286
+ # ├── index.esm.js (ES modules)
287
+ # └── index.cjs.js (CommonJS)
288
+ ```
289
+
290
+ ## Publishing to npm
291
+
292
+ 1. Update version in `package.json`
293
+ 2. Build: `npm run build`
294
+ 3. Test locally: `npm pack`
295
+ 4. Publish: `npm publish`
296
+
297
+ For private registry:
298
+ ```bash
299
+ npm publish --registry=https://your-private-registry.com
300
+ ```
301
+
302
+ ## Troubleshooting
303
+
304
+ ### Popup is blocked
305
+ - Ensure login is triggered by direct user action (click event)
306
+ - Some browsers block popups from async operations
307
+ - Check browser console for popup blocker warnings
308
+
309
+ ### Message not received
310
+ - Verify `issuerOrigin` matches exactly (protocol, domain, port)
311
+ - Check Network tab in DevTools for postMessage calls
312
+ - Ensure popup is not on cross-origin without proper headers
313
+
314
+ ### Token not persisting
315
+ - Check localStorage/sessionStorage permissions
316
+ - Verify HTTPS in production
317
+ - Check cookie policies and SameSite attributes
318
+
319
+ ## License
320
+
321
+ MIT
322
+
323
+ ## Support
324
+
325
+ For issues or questions, open an issue on the Nocal Auth repository.
@@ -0,0 +1,37 @@
1
+ export type PopupResult<T = Record<string, unknown>> = {
2
+ type: "success";
3
+ data: T;
4
+ } | {
5
+ type: "error";
6
+ error: string;
7
+ } | {
8
+ type: "closed";
9
+ };
10
+ export interface NocalAuthOptions {
11
+ clientId: string;
12
+ redirectUri: string;
13
+ scope?: string;
14
+ state?: string;
15
+ issuerOrigin?: string;
16
+ responseType?: "code" | "token";
17
+ popupFeatures?: string;
18
+ extraParams?: Record<string, string | number | boolean | undefined>;
19
+ }
20
+ export interface PopupOptions {
21
+ width?: number;
22
+ height?: number;
23
+ popupName?: string;
24
+ }
25
+ export declare class NocalAuth {
26
+ private readonly clientId;
27
+ private readonly redirectUri;
28
+ private readonly scope?;
29
+ private readonly state?;
30
+ private readonly issuerOrigin;
31
+ private readonly responseType;
32
+ private readonly popupFeatures?;
33
+ private readonly extraParams?;
34
+ constructor(options: NocalAuthOptions);
35
+ private getAuthorizeUrl;
36
+ loginWithPopup(popupOptions?: PopupOptions): Promise<Record<string, unknown>>;
37
+ }
@@ -0,0 +1,133 @@
1
+ function buildQuery(params) {
2
+ const qs = Object.entries(params)
3
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
4
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
5
+ .join("&");
6
+ return qs ? `?${qs}` : "";
7
+ }
8
+ export class NocalAuth {
9
+ constructor(options) {
10
+ if (!options.clientId) {
11
+ throw new Error("NocalAuth requires a clientId");
12
+ }
13
+ if (!options.redirectUri) {
14
+ throw new Error("NocalAuth requires a redirectUri");
15
+ }
16
+ this.clientId = options.clientId;
17
+ this.redirectUri = options.redirectUri;
18
+ this.scope = options.scope;
19
+ this.state = options.state;
20
+ this.issuerOrigin = (options.issuerOrigin ?? "https://auth.nocal.vn").replace(/\/$/, "");
21
+ this.responseType = options.responseType ?? "code";
22
+ this.popupFeatures = options.popupFeatures;
23
+ this.extraParams = options.extraParams;
24
+ }
25
+ getAuthorizeUrl() {
26
+ const baseUrl = `${this.issuerOrigin}/authorize`;
27
+ const query = buildQuery({
28
+ client_id: this.clientId,
29
+ redirect_uri: this.redirectUri,
30
+ response_type: this.responseType,
31
+ scope: this.scope,
32
+ state: this.state,
33
+ ...this.extraParams,
34
+ });
35
+ return `${baseUrl}${query}`;
36
+ }
37
+ async loginWithPopup(popupOptions = {}) {
38
+ if (typeof window === "undefined") {
39
+ throw new Error("loginWithPopup() can only be used in a browser environment");
40
+ }
41
+ const width = popupOptions.width ?? 500;
42
+ const height = popupOptions.height ?? 700;
43
+ const popupName = popupOptions.popupName ?? "nocal_auth_popup";
44
+ const dualScreenLeft = window.screenLeft ?? window.screenX ?? 0;
45
+ const dualScreenTop = window.screenTop ?? window.screenY ?? 0;
46
+ const screenWidth = window.innerWidth ??
47
+ document.documentElement.clientWidth ??
48
+ window.screen.width ??
49
+ 1280;
50
+ const screenHeight = window.innerHeight ??
51
+ document.documentElement.clientHeight ??
52
+ window.screen.height ??
53
+ 720;
54
+ const left = Math.round(dualScreenLeft + (screenWidth - width) / 2);
55
+ const top = Math.round(dualScreenTop + (screenHeight - height) / 2);
56
+ const features = [
57
+ `width=${width}`,
58
+ `height=${height}`,
59
+ `left=${left}`,
60
+ `top=${top}`,
61
+ "resizable=yes",
62
+ "scrollbars=yes",
63
+ this.popupFeatures ?? "",
64
+ ]
65
+ .filter(Boolean)
66
+ .join(",");
67
+ const popup = window.open(this.getAuthorizeUrl(), popupName, features);
68
+ if (!popup) {
69
+ throw new Error("POPUP_BLOCKED");
70
+ }
71
+ popup.focus?.();
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ let checkInterval = undefined;
75
+ const cleanup = () => {
76
+ settled = true;
77
+ window.removeEventListener("message", handleMessage);
78
+ if (checkInterval) {
79
+ clearInterval(checkInterval);
80
+ }
81
+ };
82
+ const rejectWith = (message) => {
83
+ if (settled)
84
+ return;
85
+ cleanup();
86
+ reject(new Error(message));
87
+ };
88
+ const handleMessage = (event) => {
89
+ if (settled)
90
+ return;
91
+ if (event.origin !== this.issuerOrigin) {
92
+ return;
93
+ }
94
+ const payload = event.data;
95
+ if (!payload || typeof payload !== "object") {
96
+ return;
97
+ }
98
+ const isValidPayload = payload.type === "nocal_auth" ||
99
+ payload.source === "nocal_auth" ||
100
+ Boolean(payload.code ||
101
+ payload.token ||
102
+ payload.access_token ||
103
+ payload.id_token ||
104
+ payload.error);
105
+ if (!isValidPayload) {
106
+ return;
107
+ }
108
+ cleanup();
109
+ if (payload.success === false || payload.error) {
110
+ rejectWith(typeof payload.error === "string" ? payload.error : "AUTH_FAILED");
111
+ return;
112
+ }
113
+ const data = payload.data ?? {
114
+ code: payload.code,
115
+ token: payload.token ?? payload.access_token,
116
+ id_token: payload.id_token,
117
+ state: payload.state,
118
+ };
119
+ if (!data || Object.keys(data).length === 0) {
120
+ rejectWith("AUTH_DATA_MISSING");
121
+ return;
122
+ }
123
+ resolve(data);
124
+ };
125
+ window.addEventListener("message", handleMessage, false);
126
+ checkInterval = window.setInterval(() => {
127
+ if (popup.closed) {
128
+ rejectWith("POPUP_CLOSED");
129
+ }
130
+ }, 500);
131
+ });
132
+ }
133
+ }
@@ -0,0 +1,2 @@
1
+ export { NocalAuth } from './NocalAuth';
2
+ export type { NocalAuthOptions, PopupOptions, PopupResult } from './NocalAuth';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { NocalAuth } from './NocalAuth';
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "nocal-auth-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Nocal Auth - client-side SDK for Login with Nocal (popup flow)",
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
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepare": "npm run build"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "keywords": [
24
+ "nocal",
25
+ "oauth",
26
+ "popup",
27
+ "auth",
28
+ "supabase"
29
+ ],
30
+ "author": "",
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "typescript": "^5.0.0"
34
+ }
35
+ }