@docyrus/app-auth-ui 0.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@docyrus/app-auth-ui",
3
+ "version": "0.0.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/Docyrus/docyrus-devkit.git",
7
+ "directory": "packages/app-auth-ui"
8
+ },
9
+ "type": "module",
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ }
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "25.2.2",
21
+ "@types/react": "19.2.13",
22
+ "react": "19.2.4",
23
+ "tsup": "8.5.1",
24
+ "typescript": "5.9.3",
25
+ "@docyrus/api-client": "0.0.7"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=18.0.0 | >=19.0.0",
29
+ "@docyrus/api-client": ">=0.0.4"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup",
33
+ "dev": "tsup --watch",
34
+ "lint": "eslint src",
35
+ "format": "eslint src --fix",
36
+ "typecheck": "eslint src && tsc --noEmit",
37
+ "deploy": "pnpm run build && pnpm publish --access public"
38
+ }
39
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ createContext,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ useMemo,
7
+ type ReactNode
8
+ } from 'react';
9
+
10
+ import { type OAuth2Tokens, type RestApiClient } from '@docyrus/api-client';
11
+
12
+ import {
13
+ type AuthStatus,
14
+ type AuthMode,
15
+ type DocyrusAuthContextValue,
16
+ type DocyrusAuthConfig
17
+ } from '../types';
18
+
19
+ import { AuthManager } from '../core/auth-manager';
20
+
21
+ export const DocyrusAuthContext = createContext<DocyrusAuthContextValue | null>(null);
22
+
23
+ export interface DocyrusAuthProviderProps extends DocyrusAuthConfig {
24
+ children: ReactNode;
25
+ }
26
+
27
+ export function DocyrusAuthProvider({
28
+ children,
29
+ ...config
30
+ }: DocyrusAuthProviderProps) {
31
+ const [status, setStatus] = useState<AuthStatus>('loading');
32
+ const [tokens, setTokens] = useState<OAuth2Tokens | null>(null);
33
+ const [client, setClient] = useState<RestApiClient | null>(null);
34
+ const [mode, setMode] = useState<AuthMode | null>(null);
35
+ const [error, setError] = useState<Error | null>(null);
36
+ const managerRef = useRef<AuthManager | null>(null);
37
+
38
+ useEffect(() => {
39
+ const manager = new AuthManager(config);
40
+
41
+ managerRef.current = manager;
42
+ setMode(manager.getMode());
43
+
44
+ const unsubscribe = manager.subscribe((state) => {
45
+ setStatus(state.status);
46
+ setTokens(state.tokens);
47
+ setError(state.error);
48
+ setClient(manager.getClient());
49
+ });
50
+
51
+ manager.initialize();
52
+
53
+ return () => {
54
+ unsubscribe();
55
+ manager.destroy();
56
+ };
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, []);
59
+
60
+ const signIn = useMemo(() => {
61
+ return () => {
62
+ managerRef.current?.signIn();
63
+ };
64
+ }, []);
65
+
66
+ const signOut = useMemo(() => {
67
+ return async () => {
68
+ await managerRef.current?.signOut();
69
+ };
70
+ }, []);
71
+
72
+ const value = useMemo<DocyrusAuthContextValue>(() => ({
73
+ status,
74
+ mode,
75
+ client,
76
+ tokens,
77
+ signIn,
78
+ signOut,
79
+ error
80
+ }), [
81
+ status,
82
+ mode,
83
+ client,
84
+ tokens,
85
+ signIn,
86
+ signOut,
87
+ error
88
+ ]);
89
+
90
+ return (
91
+ <DocyrusAuthContext.Provider value={value}>
92
+ {children}
93
+ </DocyrusAuthContext.Provider>
94
+ );
95
+ }
@@ -0,0 +1,62 @@
1
+ import { type ReactNode, type CSSProperties } from 'react';
2
+
3
+ import { type AuthStatus } from '../types';
4
+
5
+ import { useDocyrusAuth } from '../hooks/use-docyrus-auth';
6
+
7
+ export interface SignInButtonProps {
8
+ /** Custom label. Defaults to "Sign in with Docyrus" */
9
+ label?: string;
10
+ /** CSS class name for the button */
11
+ className?: string;
12
+ /** Inline styles */
13
+ style?: CSSProperties;
14
+ /** Custom render function. Receives signIn and status. */
15
+ children?: (props: {
16
+ signIn: () => void;
17
+ status: AuthStatus;
18
+ }) => ReactNode;
19
+ /** Disable the button */
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * "Sign in with Docyrus" button.
25
+ *
26
+ * In iframe mode: renders nothing (auth is handled by the host).
27
+ * When authenticated: renders nothing.
28
+ *
29
+ * Intentionally unstyled — use className, style, or the render-prop
30
+ * children pattern for full customization.
31
+ */
32
+ export function SignInButton({
33
+ label = 'Sign in with Docyrus',
34
+ className,
35
+ style,
36
+ children,
37
+ disabled = false
38
+ }: SignInButtonProps) {
39
+ const { signIn, status, mode } = useDocyrusAuth();
40
+
41
+ // In iframe mode, the host handles sign-in
42
+ if (mode === 'iframe') return null;
43
+
44
+ // Already authenticated
45
+ if (status === 'authenticated') return null;
46
+
47
+ // Custom render
48
+ if (children) {
49
+ return <>{children({ signIn, status })}</>;
50
+ }
51
+
52
+ return (
53
+ <button
54
+ type="button"
55
+ onClick={signIn}
56
+ disabled={disabled || status === 'loading'}
57
+ className={className}
58
+ style={style}>
59
+ {status === 'loading' ? 'Loading...' : label}
60
+ </button>
61
+ );
62
+ }
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_API_URL = 'https://alpha-api.docyrus.com';
2
+
3
+ export const DEFAULT_OAUTH_CLIENT_ID = '90565525-8283-4881-82a9-8613eb82ae27';
4
+
5
+ export const DEFAULT_OAUTH_SCOPES = [
6
+ 'offline_access',
7
+ 'Read.All',
8
+ 'Users.Read',
9
+ 'Users.Read.All',
10
+ 'DS.Read.All'
11
+ ];
12
+
13
+ export const DEFAULT_CALLBACK_PATH = '/auth/callback';
14
+
15
+ export const DEFAULT_ALLOWED_HOST_PATTERN = /^https:\/\/[^/]+\.docyrus\.app$/;
16
+
17
+ /** localStorage key to persist the pre-redirect location */
18
+ export const REDIRECT_RETURN_KEY = 'docyrus_auth_return_url';
19
+
20
+ /** Buffer in seconds before token expiration to trigger refresh */
21
+ export const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
22
+
23
+ /** Timeout for iframe token refresh requests (ms) */
24
+ export const IFRAME_TOKEN_REFRESH_TIMEOUT_MS = 10_000;
@@ -0,0 +1,46 @@
1
+ import { type AuthMode } from '../types';
2
+
3
+ import { DEFAULT_ALLOWED_HOST_PATTERN } from '../constants';
4
+
5
+ /**
6
+ * Detect whether the app is inside an iframe from a trusted *.docyrus.app host
7
+ * or running as a standalone page.
8
+ *
9
+ * If we are in an iframe but NOT from a trusted origin, we fall back
10
+ * to standalone mode (graceful degradation).
11
+ */
12
+ export function detectAuthMode(
13
+ allowedOrigins?: string[],
14
+ allowedPattern?: RegExp
15
+ ): AuthMode {
16
+ if (typeof window === 'undefined') return 'standalone';
17
+
18
+ let isInIframe = false;
19
+
20
+ try {
21
+ isInIframe = window.self !== window.top;
22
+ } catch {
23
+ // Cross-origin iframe throws — so we ARE in an iframe
24
+ isInIframe = true;
25
+ }
26
+
27
+ if (!isInIframe) return 'standalone';
28
+
29
+ /*
30
+ * We're in an iframe. Verify the host origin is trusted via document.referrer.
31
+ * The actual security is enforced by validating event.origin on every postMessage.
32
+ */
33
+ const pattern = allowedPattern ?? DEFAULT_ALLOWED_HOST_PATTERN;
34
+
35
+ try {
36
+ const referrerOrigin = new URL(document.referrer).origin;
37
+
38
+ if (allowedOrigins?.includes(referrerOrigin)) return 'iframe';
39
+ if (pattern.test(referrerOrigin)) return 'iframe';
40
+ } catch {
41
+ // Invalid or empty referrer
42
+ }
43
+
44
+ // In iframe but not from a trusted host — treat as standalone
45
+ return 'standalone';
46
+ }
@@ -0,0 +1,312 @@
1
+ import {
2
+ RestApiClient,
3
+ type OAuth2Tokens,
4
+ type TokenManager
5
+ } from '@docyrus/api-client';
6
+
7
+ import { type AuthMode, type AuthStatus, type DocyrusAuthConfig } from '../types';
8
+
9
+ import { detectAuthMode } from './auth-detector';
10
+ import { StandaloneOAuth2Auth } from './oauth2-auth';
11
+ import { IframeAuth } from './iframe-auth';
12
+ import { DEFAULT_API_URL, TOKEN_EXPIRY_BUFFER_SECONDS } from '../constants';
13
+
14
+ export type AuthStateListener = (state: {
15
+ status: AuthStatus;
16
+ tokens: OAuth2Tokens | null;
17
+ error: Error | null;
18
+ }) => void;
19
+
20
+ /**
21
+ * Unified authentication manager.
22
+ * Orchestrates standalone OAuth2 and iframe postMessage modes,
23
+ * exposes a pre-configured RestApiClient, and manages token lifecycle.
24
+ */
25
+ export class AuthManager {
26
+ private mode: AuthMode;
27
+ private status: AuthStatus = 'loading';
28
+ private tokens: OAuth2Tokens | null = null;
29
+ private error: Error | null = null;
30
+ private client: RestApiClient | null = null;
31
+ private standaloneAuth: StandaloneOAuth2Auth | null = null;
32
+ private iframeAuth: IframeAuth | null = null;
33
+ private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
34
+ private listeners: Set<AuthStateListener> = new Set();
35
+ private config: DocyrusAuthConfig;
36
+ constructor(config: DocyrusAuthConfig = {}) {
37
+ this.config = config;
38
+ this.mode = config.forceMode
39
+ ?? detectAuthMode(config.allowedHostOrigins);
40
+ }
41
+ getMode(): AuthMode {
42
+ return this.mode;
43
+ }
44
+ getStatus(): AuthStatus {
45
+ return this.status;
46
+ }
47
+ getTokens(): OAuth2Tokens | null {
48
+ return this.tokens;
49
+ }
50
+ getClient(): RestApiClient | null {
51
+ return this.client;
52
+ }
53
+ getError(): Error | null {
54
+ return this.error;
55
+ }
56
+ subscribe(listener: AuthStateListener): () => void {
57
+ this.listeners.add(listener);
58
+
59
+ return () => this.listeners.delete(listener);
60
+ }
61
+ private notify(): void {
62
+ for (const listener of this.listeners) {
63
+ listener({
64
+ status: this.status,
65
+ tokens: this.tokens,
66
+ error: this.error
67
+ });
68
+ }
69
+ }
70
+ /** Initialize the auth manager. Must be called once on mount. */
71
+ async initialize(): Promise<void> {
72
+ try {
73
+ if (this.mode === 'iframe') {
74
+ this.initializeIframeMode();
75
+ } else {
76
+ await this.initializeStandaloneMode();
77
+ }
78
+ } catch (err) {
79
+ this.error = err instanceof Error ? err : new Error(String(err));
80
+ this.status = 'unauthenticated';
81
+ this.notify();
82
+ }
83
+ }
84
+ /**
85
+ * Standalone mode initialization.
86
+ * 1. Check if we're returning from an OAuth callback.
87
+ * 2. If not, check for existing tokens in localStorage.
88
+ * 3. If tokens are expired, try to refresh.
89
+ */
90
+ private async initializeStandaloneMode(): Promise<void> {
91
+ this.standaloneAuth = new StandaloneOAuth2Auth(this.config);
92
+
93
+ // Case 1: We're at the callback URL after OAuth redirect
94
+ if (this.standaloneAuth.isCallbackUrl()) {
95
+ const tokens = await this.standaloneAuth.handleCallback();
96
+
97
+ this.setAuthenticated(tokens);
98
+
99
+ // Navigate away from the callback URL
100
+ const returnUrl = this.standaloneAuth.getReturnUrl();
101
+
102
+ if (returnUrl) {
103
+ window.location.replace(returnUrl);
104
+ } else {
105
+ window.history.replaceState({}, '', '/');
106
+ }
107
+
108
+ return;
109
+ }
110
+
111
+ // Case 2: Check for existing tokens
112
+ const stored = await this.standaloneAuth.getStoredTokens();
113
+
114
+ if (stored) {
115
+ const isExpired = await this.standaloneAuth.isTokenExpired();
116
+
117
+ if (!isExpired) {
118
+ this.setAuthenticated(stored);
119
+
120
+ return;
121
+ }
122
+
123
+ // Token expired, try refresh
124
+ if (stored.refreshToken) {
125
+ try {
126
+ const newTokens = await this.standaloneAuth.refreshToken();
127
+
128
+ this.setAuthenticated(newTokens);
129
+
130
+ return;
131
+ } catch {
132
+ // Refresh failed, require re-login
133
+ }
134
+ }
135
+ }
136
+
137
+ // Case 3: No tokens or refresh failed
138
+ this.status = 'unauthenticated';
139
+ this.notify();
140
+ }
141
+ /**
142
+ * Iframe mode initialization.
143
+ * Listen for postMessage tokens from the host.
144
+ * Status remains 'loading' until the host sends the first signin message.
145
+ */
146
+ private initializeIframeMode(): void {
147
+ this.iframeAuth = new IframeAuth(this.config.allowedHostOrigins);
148
+
149
+ this.iframeAuth.start((tokens: OAuth2Tokens) => {
150
+ this.setAuthenticated(tokens);
151
+ });
152
+ }
153
+ /** Initiate sign-in. Only meaningful in standalone mode. */
154
+ async signIn(): Promise<void> {
155
+ if (this.mode !== 'standalone' || !this.standaloneAuth) return;
156
+
157
+ await this.standaloneAuth.initiateLogin();
158
+ }
159
+ /**
160
+ * Sign out.
161
+ * Standalone: revoke token, clear localStorage, reset state.
162
+ * Iframe: clear local state (host manages the actual session).
163
+ */
164
+ async signOut(): Promise<void> {
165
+ this.clearTokenRefreshTimer();
166
+
167
+ if (this.mode === 'standalone' && this.standaloneAuth) {
168
+ await this.standaloneAuth.logout();
169
+ }
170
+
171
+ if (this.mode === 'iframe' && this.iframeAuth) {
172
+ this.iframeAuth.stop();
173
+ }
174
+
175
+ this.tokens = null;
176
+ this.client = null;
177
+ this.status = 'unauthenticated';
178
+ this.error = null;
179
+ this.notify();
180
+ }
181
+ /**
182
+ * Called when valid tokens are received (either mode).
183
+ * Creates/updates the RestApiClient and schedules token refresh.
184
+ */
185
+ private setAuthenticated(tokens: OAuth2Tokens): void {
186
+ this.tokens = tokens;
187
+ this.error = null;
188
+ this.status = 'authenticated';
189
+
190
+ this.createClient();
191
+ this.scheduleTokenRefresh(tokens);
192
+ this.notify();
193
+ }
194
+ /**
195
+ * Create a RestApiClient with a custom TokenManager that proactively
196
+ * refreshes the token in getToken(). This is necessary because
197
+ * BaseApiClient.getAccessToken() only calls tokenManager.getToken()
198
+ * and does NOT auto-call refreshToken() when the token is expired.
199
+ */
200
+ private createClient(): void {
201
+ const apiUrl = this.config.apiUrl ?? DEFAULT_API_URL;
202
+
203
+ const tokenManager: TokenManager = {
204
+ getToken: () => this.getValidToken(),
205
+ setToken: (token: string) => {
206
+ if (this.tokens) {
207
+ this.tokens = { ...this.tokens, accessToken: token };
208
+ }
209
+ },
210
+ clearToken: () => {
211
+ this.tokens = null;
212
+ },
213
+ refreshToken: () => this.handleTokenRefresh()
214
+ };
215
+
216
+ this.client = new RestApiClient({
217
+ baseURL: apiUrl,
218
+ tokenManager
219
+ });
220
+ }
221
+ /**
222
+ * Get a valid access token, proactively refreshing if expired.
223
+ * Called by the RestApiClient on every request via tokenManager.getToken().
224
+ */
225
+ private async getValidToken(): Promise<string | null> {
226
+ if (!this.tokens) return null;
227
+
228
+ // Check if token is expired (with buffer)
229
+ if (this.tokens.expiresAt) {
230
+ const nowSec = Math.floor(Date.now() / 1000);
231
+
232
+ if (nowSec >= this.tokens.expiresAt - TOKEN_EXPIRY_BUFFER_SECONDS) {
233
+ try {
234
+ const freshToken = await this.handleTokenRefresh();
235
+
236
+ return freshToken;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+ }
242
+
243
+ return this.tokens.accessToken;
244
+ }
245
+ /**
246
+ * Handle token refresh depending on mode.
247
+ * Standalone: use OAuth2Client.getValidAccessToken() which auto-refreshes.
248
+ * Iframe: send postMessage to host requesting new tokens.
249
+ */
250
+ private async handleTokenRefresh(): Promise<string> {
251
+ if (this.mode === 'standalone' && this.standaloneAuth) {
252
+ const newTokens = await this.standaloneAuth.refreshToken();
253
+
254
+ this.tokens = newTokens;
255
+ this.scheduleTokenRefresh(newTokens);
256
+ this.notify();
257
+
258
+ return newTokens.accessToken;
259
+ }
260
+
261
+ if (this.mode === 'iframe' && this.iframeAuth) {
262
+ const newTokens = await this.iframeAuth.requestTokenRefresh();
263
+
264
+ return newTokens.accessToken;
265
+ }
266
+
267
+ throw new Error('Cannot refresh token: auth not initialized');
268
+ }
269
+ /** Schedule proactive token refresh before expiry. */
270
+ private scheduleTokenRefresh(tokens: OAuth2Tokens): void {
271
+ this.clearTokenRefreshTimer();
272
+
273
+ if (!tokens.expiresAt) return;
274
+
275
+ const expiresAtMs = tokens.expiresAt * 1000;
276
+ const bufferMs = TOKEN_EXPIRY_BUFFER_SECONDS * 1000;
277
+ const refreshInMs = expiresAtMs - bufferMs - Date.now();
278
+
279
+ if (refreshInMs <= 0) {
280
+ // Already needs refresh
281
+ this.handleTokenRefresh().catch((err) => {
282
+ this.error = err instanceof Error ? err : new Error(String(err));
283
+ this.status = 'unauthenticated';
284
+ this.notify();
285
+ });
286
+
287
+ return;
288
+ }
289
+
290
+ this.tokenRefreshTimer = setTimeout(() => {
291
+ this.handleTokenRefresh().catch((err) => {
292
+ this.error = err instanceof Error ? err : new Error(String(err));
293
+ this.status = 'unauthenticated';
294
+ this.notify();
295
+ });
296
+ }, refreshInMs);
297
+ }
298
+ private clearTokenRefreshTimer(): void {
299
+ if (this.tokenRefreshTimer) {
300
+ clearTimeout(this.tokenRefreshTimer);
301
+ this.tokenRefreshTimer = null;
302
+ }
303
+ }
304
+ /** Cleanup: remove listeners, timers, stop iframe auth. */
305
+ destroy(): void {
306
+ this.clearTokenRefreshTimer();
307
+
308
+ if (this.iframeAuth) this.iframeAuth.stop();
309
+
310
+ this.listeners.clear();
311
+ }
312
+ }