@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.
@@ -0,0 +1,135 @@
1
+ import { type OAuth2Tokens } from '@docyrus/api-client';
2
+
3
+ import { type HostSignInMessage, type HostToAppMessage } from '../types';
4
+
5
+ import {
6
+ DEFAULT_ALLOWED_HOST_PATTERN,
7
+ IFRAME_TOKEN_REFRESH_TIMEOUT_MS
8
+ } from '../constants';
9
+
10
+ type IframeAuthCallback = (tokens: OAuth2Tokens) => void;
11
+
12
+ /**
13
+ * Iframe authentication via postMessage.
14
+ *
15
+ * Protocol:
16
+ * - Host → App: { type: "signin", accessToken, refreshToken }
17
+ * - App → Host: { type: "token-refresh-request" }
18
+ *
19
+ * Every incoming message's event.origin is validated against
20
+ * the allowed host pattern before processing.
21
+ */
22
+ export class IframeAuth {
23
+ private allowedOrigins: string[];
24
+ private allowedPattern: RegExp;
25
+ private onTokensReceived: IframeAuthCallback | null = null;
26
+ private refreshPromise: {
27
+ resolve: (tokens: OAuth2Tokens) => void;
28
+ reject: (error: Error) => void;
29
+ timeoutId: ReturnType<typeof setTimeout>;
30
+ } | null = null;
31
+ private messageHandler: ((ev: MessageEvent) => void) | null = null;
32
+ constructor(
33
+ allowedOrigins?: string[],
34
+ allowedPattern?: RegExp
35
+ ) {
36
+ this.allowedOrigins = allowedOrigins ?? [];
37
+ this.allowedPattern = allowedPattern ?? DEFAULT_ALLOWED_HOST_PATTERN;
38
+ }
39
+ /**
40
+ * Start listening for postMessage events from the host.
41
+ * The callback is invoked every time valid tokens are received.
42
+ */
43
+ start(onTokensReceived: IframeAuthCallback): void {
44
+ this.onTokensReceived = onTokensReceived;
45
+ this.messageHandler = this.handleMessage.bind(this);
46
+ window.addEventListener('message', this.messageHandler);
47
+ }
48
+ /** Stop listening for postMessage events. */
49
+ stop(): void {
50
+ if (this.messageHandler) {
51
+ window.removeEventListener('message', this.messageHandler);
52
+ this.messageHandler = null;
53
+ }
54
+ this.rejectPendingRefresh('IframeAuth stopped');
55
+ }
56
+ /**
57
+ * Request fresh tokens from the host.
58
+ * Posts a "token-refresh-request" to the host, then waits for
59
+ * a "signin" response.
60
+ *
61
+ * Only one refresh request can be in-flight at a time.
62
+ * Additional callers share the same promise.
63
+ */
64
+ requestTokenRefresh(): Promise<OAuth2Tokens> {
65
+ if (this.refreshPromise) {
66
+ return new Promise<OAuth2Tokens>((resolve, reject) => {
67
+ const existing = this.refreshPromise!;
68
+ const originalResolve = existing.resolve;
69
+ const originalReject = existing.reject;
70
+
71
+ existing.resolve = (tokens) => {
72
+ originalResolve(tokens);
73
+ resolve(tokens);
74
+ };
75
+ existing.reject = (error) => {
76
+ originalReject(error);
77
+ reject(error);
78
+ };
79
+ });
80
+ }
81
+
82
+ return new Promise<OAuth2Tokens>((resolve, reject) => {
83
+ const timeoutId = setTimeout(() => {
84
+ this.refreshPromise = null;
85
+ reject(new Error('Token refresh request timed out'));
86
+ }, IFRAME_TOKEN_REFRESH_TIMEOUT_MS);
87
+
88
+ this.refreshPromise = { resolve, reject, timeoutId };
89
+
90
+ window.parent.postMessage({ type: 'token-refresh-request' }, '*');
91
+ });
92
+ }
93
+ private isOriginAllowed(origin: string): boolean {
94
+ if (this.allowedOrigins.includes(origin)) return true;
95
+
96
+ return this.allowedPattern.test(origin);
97
+ }
98
+ private handleMessage(ev: MessageEvent): void {
99
+ if (!this.isOriginAllowed(ev.origin)) return;
100
+
101
+ const data = ev.data as HostToAppMessage;
102
+
103
+ if (!data || typeof data !== 'object' || data.type !== 'signin') return;
104
+
105
+ const signinData = data as HostSignInMessage;
106
+
107
+ if (!signinData.accessToken) return;
108
+
109
+ const tokens: OAuth2Tokens = {
110
+ accessToken: signinData.accessToken,
111
+ refreshToken: signinData.refreshToken,
112
+ /*
113
+ * We don't know the exact expiry from the host, so estimate 1 hour.
114
+ * The host will send new tokens before expiry via the refresh mechanism.
115
+ */
116
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
117
+ scope: ''
118
+ };
119
+
120
+ this.onTokensReceived?.(tokens);
121
+
122
+ if (this.refreshPromise) {
123
+ clearTimeout(this.refreshPromise.timeoutId);
124
+ this.refreshPromise.resolve(tokens);
125
+ this.refreshPromise = null;
126
+ }
127
+ }
128
+ private rejectPendingRefresh(reason: string): void {
129
+ if (this.refreshPromise) {
130
+ clearTimeout(this.refreshPromise.timeoutId);
131
+ this.refreshPromise.reject(new Error(reason));
132
+ this.refreshPromise = null;
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ OAuth2Client,
3
+ BrowserOAuth2TokenStorage,
4
+ type OAuth2Tokens
5
+ } from '@docyrus/api-client';
6
+
7
+ import { type DocyrusAuthConfig } from '../types';
8
+
9
+ import {
10
+ DEFAULT_API_URL,
11
+ DEFAULT_OAUTH_CLIENT_ID,
12
+ DEFAULT_OAUTH_SCOPES,
13
+ DEFAULT_CALLBACK_PATH,
14
+ REDIRECT_RETURN_KEY
15
+ } from '../constants';
16
+
17
+ /**
18
+ * Standalone OAuth2 authorization code flow with PKCE via page redirect.
19
+ * Wraps api-client's OAuth2Client and BrowserOAuth2TokenStorage.
20
+ */
21
+ export class StandaloneOAuth2Auth {
22
+ private oauth2Client: OAuth2Client;
23
+ private storage: BrowserOAuth2TokenStorage;
24
+ private callbackPath: string;
25
+ constructor(config: DocyrusAuthConfig = {}) {
26
+ const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
27
+ const clientId = config.clientId ?? DEFAULT_OAUTH_CLIENT_ID;
28
+ const scopes = config.scopes ?? DEFAULT_OAUTH_SCOPES;
29
+
30
+ this.callbackPath = config.callbackPath ?? DEFAULT_CALLBACK_PATH;
31
+ const redirectUri = config.redirectUri
32
+ ?? `${window.location.origin}${this.callbackPath}`;
33
+
34
+ this.storage = new BrowserOAuth2TokenStorage(
35
+ window.localStorage,
36
+ config.storageKeyPrefix ?? 'docyrus_oauth2'
37
+ );
38
+
39
+ this.oauth2Client = new OAuth2Client({
40
+ baseURL: apiUrl,
41
+ clientId,
42
+ redirectUri,
43
+ defaultScopes: scopes,
44
+ usePKCE: true,
45
+ tokenStorage: this.storage
46
+ });
47
+ }
48
+ /**
49
+ * Check if the current page URL is the OAuth2 callback.
50
+ * Called on app initialization to detect returning from the auth server.
51
+ */
52
+ isCallbackUrl(): boolean {
53
+ return window.location.pathname === this.callbackPath
54
+ && (window.location.search.includes('code=')
55
+ || window.location.search.includes('error='));
56
+ }
57
+ /**
58
+ * Initiate the OAuth2 authorization code flow.
59
+ * Stores the current URL for post-auth redirect, then navigates
60
+ * the page to the authorization endpoint.
61
+ *
62
+ * PKCE state (codeVerifier, state) is stored in localStorage
63
+ * by the OAuth2Client and survives the page redirect.
64
+ */
65
+ async initiateLogin(): Promise<void> {
66
+ window.localStorage.setItem(REDIRECT_RETURN_KEY, window.location.href);
67
+
68
+ const { url } = await this.oauth2Client.getAuthorizationUrl();
69
+
70
+ window.location.href = url;
71
+ }
72
+ /**
73
+ * Handle the OAuth2 callback URL.
74
+ * Reads PKCE state from localStorage, validates the state param,
75
+ * exchanges the authorization code for tokens, and stores them.
76
+ */
77
+ async handleCallback(): Promise<OAuth2Tokens> {
78
+ return this.oauth2Client.handleCallback(window.location.href);
79
+ }
80
+ /**
81
+ * Get the URL the user was on before the OAuth redirect.
82
+ * Clears the stored value after retrieval.
83
+ */
84
+ getReturnUrl(): string | null {
85
+ const url = window.localStorage.getItem(REDIRECT_RETURN_KEY);
86
+
87
+ window.localStorage.removeItem(REDIRECT_RETURN_KEY);
88
+
89
+ return url;
90
+ }
91
+ /** Get currently stored tokens (e.g., on page reload). */
92
+ async getStoredTokens(): Promise<OAuth2Tokens | null> {
93
+ return this.oauth2Client.getTokens();
94
+ }
95
+ /** Check if the stored token is expired (with 60s buffer). */
96
+ async isTokenExpired(): Promise<boolean> {
97
+ return this.oauth2Client.isTokenExpired();
98
+ }
99
+ /** Refresh the access token using the stored refresh token. */
100
+ async refreshToken(): Promise<OAuth2Tokens> {
101
+ return this.oauth2Client.refreshAccessToken();
102
+ }
103
+ /** Get a valid access token, refreshing if needed. */
104
+ async getValidAccessToken(): Promise<string> {
105
+ return this.oauth2Client.getValidAccessToken();
106
+ }
107
+ /** Logout: revoke token and clear storage. */
108
+ async logout(): Promise<void> {
109
+ return this.oauth2Client.logout();
110
+ }
111
+ }
@@ -0,0 +1,24 @@
1
+ import { useContext } from 'react';
2
+
3
+ import { type DocyrusAuthContextValue } from '../types';
4
+
5
+ import { DocyrusAuthContext } from '../components/docyrus-auth-provider';
6
+
7
+ /**
8
+ * Hook to access Docyrus auth state.
9
+ * Must be used within a <DocyrusAuthProvider>.
10
+ *
11
+ * Returns: { status, mode, tokens, signIn, signOut, client, error }
12
+ */
13
+ export function useDocyrusAuth(): DocyrusAuthContextValue {
14
+ const context = useContext(DocyrusAuthContext);
15
+
16
+ if (!context) {
17
+ throw new Error(
18
+ 'useDocyrusAuth must be used within a <DocyrusAuthProvider>. '
19
+ + 'Wrap your app with <DocyrusAuthProvider> to provide auth context.'
20
+ );
21
+ }
22
+
23
+ return context;
24
+ }
@@ -0,0 +1,15 @@
1
+ import { type RestApiClient } from '@docyrus/api-client';
2
+
3
+ import { useDocyrusAuth } from './use-docyrus-auth';
4
+
5
+ /**
6
+ * Hook to get the pre-configured RestApiClient.
7
+ * Returns null when not authenticated.
8
+ *
9
+ * Throws if used outside DocyrusAuthProvider.
10
+ */
11
+ export function useDocyrusClient(): RestApiClient | null {
12
+ const { client } = useDocyrusAuth();
13
+
14
+ return client;
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Provider
2
+ export { DocyrusAuthProvider, DocyrusAuthContext } from './components/docyrus-auth-provider';
3
+ export type { DocyrusAuthProviderProps } from './components/docyrus-auth-provider';
4
+
5
+ // Hooks
6
+ export { useDocyrusAuth } from './hooks/use-docyrus-auth';
7
+ export { useDocyrusClient } from './hooks/use-docyrus-client';
8
+
9
+ // Component
10
+ export { SignInButton } from './components/sign-in-button';
11
+ export type { SignInButtonProps } from './components/sign-in-button';
12
+
13
+ // Core classes (for advanced usage)
14
+ export { AuthManager } from './core/auth-manager';
15
+ export { StandaloneOAuth2Auth } from './core/oauth2-auth';
16
+ export { IframeAuth } from './core/iframe-auth';
17
+ export { detectAuthMode } from './core/auth-detector';
18
+
19
+ // Types
20
+ export type {
21
+ AuthMode,
22
+ AuthStatus,
23
+ DocyrusAuthContextValue,
24
+ DocyrusAuthConfig,
25
+ HostSignInMessage,
26
+ TokenRefreshRequestMessage
27
+ } from './types';
28
+
29
+ // Constants
30
+ export {
31
+ DEFAULT_API_URL,
32
+ DEFAULT_OAUTH_CLIENT_ID,
33
+ DEFAULT_OAUTH_SCOPES,
34
+ DEFAULT_CALLBACK_PATH
35
+ } from './constants';
package/src/types.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { type OAuth2Tokens, type RestApiClient } from '@docyrus/api-client';
2
+
3
+ /** Authentication mode detected at runtime */
4
+ export type AuthMode = 'standalone' | 'iframe';
5
+
6
+ /** Authentication state exposed to consumers */
7
+ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
8
+
9
+ /** Configuration for the DocyrusAuthProvider */
10
+ export interface DocyrusAuthConfig {
11
+ /** OAuth2 client ID. Defaults to the Docyrus public client ID */
12
+ clientId?: string;
13
+ /** API base URL. Defaults to https://alpha-api.docyrus.com */
14
+ apiUrl?: string;
15
+ /** OAuth2 scopes. Defaults to offline_access Read.All Users.Read Users.Read.All DS.Read.All */
16
+ scopes?: string[];
17
+ /** The redirect URI for OAuth2 callback. Defaults to `${window.location.origin}/auth/callback` */
18
+ redirectUri?: string;
19
+ /** The path where the OAuth2 callback is handled. Defaults to '/auth/callback' */
20
+ callbackPath?: string;
21
+ /** Allowed host origins for iframe mode. Used alongside the default *.docyrus.app pattern */
22
+ allowedHostOrigins?: string[];
23
+ /** localStorage key prefix for token storage. Defaults to 'docyrus_oauth2' */
24
+ storageKeyPrefix?: string;
25
+ /** Override auto-detection: force a specific auth mode */
26
+ forceMode?: AuthMode;
27
+ }
28
+
29
+ /** The shape of the React context value */
30
+ export interface DocyrusAuthContextValue {
31
+ /** Current auth state */
32
+ status: AuthStatus;
33
+ /** The detected authentication mode */
34
+ mode: AuthMode | null;
35
+ /** Pre-configured RestApiClient with valid tokens */
36
+ client: RestApiClient | null;
37
+ /** Current tokens (null when unauthenticated) */
38
+ tokens: OAuth2Tokens | null;
39
+ /** Initiate sign-in (only relevant in standalone mode) */
40
+ signIn: () => void;
41
+ /** Sign out and clear tokens */
42
+ signOut: () => Promise<void>;
43
+ /** Any error that occurred during auth */
44
+ error: Error | null;
45
+ }
46
+
47
+ /** PostMessage payload: host sends tokens to the embedded app */
48
+ export interface HostSignInMessage {
49
+ type: 'signin';
50
+ accessToken: string;
51
+ refreshToken: string;
52
+ }
53
+
54
+ /** PostMessage payload: app requests fresh tokens from the host */
55
+ export interface TokenRefreshRequestMessage {
56
+ type: 'token-refresh-request';
57
+ }
58
+
59
+ export type HostToAppMessage = HostSignInMessage;
60
+ export type AppToHostMessage = TokenRefreshRequestMessage;
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": [ "ES2024", "DOM", "DOM.Iterable" ],
5
+ "jsx": "react-jsx",
6
+ "rootDir": "src",
7
+ "outDir": "dist"
8
+ },
9
+ "include": [ "src" ]
10
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ splitting: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ treeshake: true,
11
+ minify: false,
12
+ esbuildOptions(options) {
13
+ options.jsx = 'automatic';
14
+ }
15
+ });