@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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/README.md +200 -0
- package/dist/index.d.ts +286 -0
- package/dist/index.js +562 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/components/docyrus-auth-provider.tsx +95 -0
- package/src/components/sign-in-button.tsx +62 -0
- package/src/constants.ts +24 -0
- package/src/core/auth-detector.ts +46 -0
- package/src/core/auth-manager.ts +312 -0
- package/src/core/iframe-auth.ts +135 -0
- package/src/core/oauth2-auth.ts +111 -0
- package/src/hooks/use-docyrus-auth.ts +24 -0
- package/src/hooks/use-docyrus-client.ts +15 -0
- package/src/index.ts +35 -0
- package/src/types.ts +60 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +15 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|