|
2 | 2 | // Inspired by ../use-mcp BrowserOAuthClientProvider but without extra deps |
3 | 3 |
|
4 | 4 | import type { |
5 | | - OAuthClientInformation, |
6 | | - OAuthTokens, |
7 | | - OAuthClientMetadata, |
8 | | -} from '@modelcontextprotocol/sdk/shared/auth.js' |
9 | | -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' |
10 | | -import { browser } from '$app/environment' |
11 | | -import type { StoredState } from './types' |
| 5 | + OAuthClientInformation, |
| 6 | + OAuthTokens, |
| 7 | + OAuthClientMetadata, |
| 8 | +} from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 9 | +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 10 | +import { browser } from "$app/environment"; |
| 11 | +import type { StoredState } from "./types"; |
12 | 12 |
|
13 | 13 | function sanitizeUrl(input: string): string { |
14 | | - try { |
15 | | - const url = new URL(input) |
16 | | - return url.toString() |
17 | | - } catch { |
18 | | - return input |
19 | | - } |
| 14 | + try { |
| 15 | + const url = new URL(input); |
| 16 | + return url.toString(); |
| 17 | + } catch { |
| 18 | + return input; |
| 19 | + } |
20 | 20 | } |
21 | 21 |
|
22 | 22 | export class BrowserOAuthClientProvider implements OAuthClientProvider { |
23 | | - readonly serverUrl: string |
24 | | - readonly storageKeyPrefix: string |
25 | | - readonly serverUrlHash: string |
26 | | - readonly clientName: string |
27 | | - readonly clientUri: string |
28 | | - readonly callbackUrl: string |
29 | | - private preventAutoAuth?: boolean |
30 | | - readonly onPopupWindow: ((url: string, features: string, win: Window | null) => void) | undefined |
31 | | - |
32 | | - constructor( |
33 | | - serverUrl: string, |
34 | | - options: { |
35 | | - storageKeyPrefix?: string |
36 | | - clientName?: string |
37 | | - clientUri?: string |
38 | | - callbackUrl?: string |
39 | | - preventAutoAuth?: boolean |
40 | | - onPopupWindow?: (url: string, features: string, win: Window | null) => void |
41 | | - } = {} |
42 | | - ) { |
43 | | - this.serverUrl = serverUrl |
44 | | - this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth' |
45 | | - this.serverUrlHash = this.hashString(serverUrl) |
46 | | - this.clientName = options.clientName || 'chat-ui' |
47 | | - this.clientUri = options.clientUri || (browser ? window.location.origin : '') |
48 | | - this.callbackUrl = sanitizeUrl( |
49 | | - options.callbackUrl || (browser ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback') |
50 | | - ) |
51 | | - this.preventAutoAuth = options.preventAutoAuth |
52 | | - this.onPopupWindow = options.onPopupWindow |
53 | | - } |
54 | | - |
55 | | - get redirectUrl(): string { |
56 | | - return sanitizeUrl(this.callbackUrl) |
57 | | - } |
58 | | - |
59 | | - get clientMetadata(): OAuthClientMetadata { |
60 | | - return { |
61 | | - redirect_uris: [this.redirectUrl], |
62 | | - token_endpoint_auth_method: 'none', |
63 | | - grant_types: ['authorization_code', 'refresh_token'], |
64 | | - response_types: ['code'], |
65 | | - client_name: this.clientName, |
66 | | - client_uri: this.clientUri, |
67 | | - } |
68 | | - } |
69 | | - |
70 | | - async clientInformation(): Promise<OAuthClientInformation | undefined> { |
71 | | - const data = browser ? localStorage.getItem(this.getKey('client_info')) : null |
72 | | - if (!data) return undefined |
73 | | - try { |
74 | | - return JSON.parse(data) as OAuthClientInformation |
75 | | - } catch { |
76 | | - localStorage.removeItem(this.getKey('client_info')) |
77 | | - return undefined |
78 | | - } |
79 | | - } |
80 | | - |
81 | | - async saveClientInformation(info: OAuthClientInformation): Promise<void> { |
82 | | - if (!browser) return |
83 | | - localStorage.setItem(this.getKey('client_info'), JSON.stringify(info)) |
84 | | - } |
85 | | - |
86 | | - async tokens(): Promise<OAuthTokens | undefined> { |
87 | | - const data = browser ? localStorage.getItem(this.getKey('tokens')) : null |
88 | | - if (!data) return undefined |
89 | | - try { |
90 | | - return JSON.parse(data) as OAuthTokens |
91 | | - } catch { |
92 | | - localStorage.removeItem(this.getKey('tokens')) |
93 | | - return undefined |
94 | | - } |
95 | | - } |
96 | | - |
97 | | - async saveTokens(tokens: OAuthTokens): Promise<void> { |
98 | | - if (!browser) return |
99 | | - localStorage.setItem(this.getKey('tokens'), JSON.stringify(tokens)) |
100 | | - localStorage.removeItem(this.getKey('code_verifier')) |
101 | | - localStorage.removeItem(this.getKey('last_auth_url')) |
102 | | - } |
103 | | - |
104 | | - async saveCodeVerifier(verifier: string): Promise<void> { |
105 | | - if (!browser) return |
106 | | - localStorage.setItem(this.getKey('code_verifier'), verifier) |
107 | | - } |
108 | | - |
109 | | - async codeVerifier(): Promise<string> { |
110 | | - const v = browser ? localStorage.getItem(this.getKey('code_verifier')) : null |
111 | | - if (!v) throw new Error('Code verifier not found') |
112 | | - return v |
113 | | - } |
114 | | - |
115 | | - async prepareAuthorizationUrl(authorizationUrl: URL): Promise<string> { |
116 | | - if (!browser) return authorizationUrl.toString() |
117 | | - const state = Math.random().toString(36).slice(2) |
118 | | - const stateKey = `${this.storageKeyPrefix}:state_${state}` |
119 | | - const expiry = Date.now() + 5 * 60 * 1000 |
120 | | - const stored: StoredState = { |
121 | | - expiry, |
122 | | - serverUrlHash: this.serverUrlHash, |
123 | | - providerOptions: { |
124 | | - serverUrl: this.serverUrl, |
125 | | - storageKeyPrefix: this.storageKeyPrefix, |
126 | | - clientName: this.clientName, |
127 | | - clientUri: this.clientUri, |
128 | | - callbackUrl: this.callbackUrl, |
129 | | - }, |
130 | | - } |
131 | | - localStorage.setItem(stateKey, JSON.stringify(stored)) |
132 | | - authorizationUrl.searchParams.set('state', state) |
133 | | - const full = authorizationUrl.toString() |
134 | | - const sanitized = sanitizeUrl(full) |
135 | | - localStorage.setItem(this.getKey('last_auth_url'), sanitized) |
136 | | - return sanitized |
137 | | - } |
138 | | - |
139 | | - async redirectToAuthorization(authorizationUrl: URL): Promise<void> { |
140 | | - if (this.preventAutoAuth || !browser) return |
141 | | - const url = await this.prepareAuthorizationUrl(authorizationUrl) |
142 | | - const features = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' |
143 | | - try { |
144 | | - const win = window.open(url, `mcp_auth_${this.serverUrlHash}`, features) |
145 | | - this.onPopupWindow?.(url, features, win) |
146 | | - if (!win || win.closed) console.warn('[mcp] popup may be blocked') |
147 | | - } catch (e) { |
148 | | - console.error('[mcp] failed to open popup', e) |
149 | | - } |
150 | | - } |
151 | | - |
152 | | - getLastAttemptedAuthUrl(): string | null { |
153 | | - if (!browser) return null |
154 | | - const v = localStorage.getItem(this.getKey('last_auth_url')) |
155 | | - return v ? sanitizeUrl(v) : null |
156 | | - } |
157 | | - |
158 | | - clearStorage(): number { |
159 | | - if (!browser) return 0 |
160 | | - const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}_` |
161 | | - const statePrefix = `${this.storageKeyPrefix}:state_` |
162 | | - const toRemove: string[] = [] |
163 | | - for (let i = 0; i < localStorage.length; i++) { |
164 | | - const k = localStorage.key(i) |
165 | | - if (!k) continue |
166 | | - if (k.startsWith(prefix)) toRemove.push(k) |
167 | | - if (k.startsWith(statePrefix)) { |
168 | | - try { |
169 | | - const raw = localStorage.getItem(k) |
170 | | - if (!raw) continue |
171 | | - const parsed = JSON.parse(raw) as Partial<StoredState> |
172 | | - if (parsed.serverUrlHash === this.serverUrlHash) toRemove.push(k) |
173 | | - } catch {} |
174 | | - } |
175 | | - } |
176 | | - const unique = [...new Set(toRemove)] |
177 | | - unique.forEach((k) => localStorage.removeItem(k)) |
178 | | - return unique.length |
179 | | - } |
180 | | - |
181 | | - getKey(suffix: string): string { |
182 | | - return `${this.storageKeyPrefix}_${this.serverUrlHash}_${suffix}` |
183 | | - } |
184 | | - |
185 | | - private hashString(str: string): string { |
186 | | - let hash = 0 |
187 | | - for (let i = 0; i < str.length; i++) { |
188 | | - hash = (hash << 5) - hash + str.charCodeAt(i) |
189 | | - hash |= 0 |
190 | | - } |
191 | | - return Math.abs(hash).toString(16) |
192 | | - } |
| 23 | + readonly serverUrl: string; |
| 24 | + readonly storageKeyPrefix: string; |
| 25 | + readonly serverUrlHash: string; |
| 26 | + readonly clientName: string; |
| 27 | + readonly clientUri: string; |
| 28 | + readonly callbackUrl: string; |
| 29 | + private preventAutoAuth?: boolean; |
| 30 | + readonly onPopupWindow: ((url: string, features: string, win: Window | null) => void) | undefined; |
| 31 | + |
| 32 | + constructor( |
| 33 | + serverUrl: string, |
| 34 | + options: { |
| 35 | + storageKeyPrefix?: string; |
| 36 | + clientName?: string; |
| 37 | + clientUri?: string; |
| 38 | + callbackUrl?: string; |
| 39 | + preventAutoAuth?: boolean; |
| 40 | + onPopupWindow?: (url: string, features: string, win: Window | null) => void; |
| 41 | + } = {} |
| 42 | + ) { |
| 43 | + this.serverUrl = serverUrl; |
| 44 | + this.storageKeyPrefix = options.storageKeyPrefix || "mcp:auth"; |
| 45 | + this.serverUrlHash = this.hashString(serverUrl); |
| 46 | + this.clientName = options.clientName || "chat-ui"; |
| 47 | + this.clientUri = options.clientUri || (browser ? window.location.origin : ""); |
| 48 | + this.callbackUrl = sanitizeUrl( |
| 49 | + options.callbackUrl || |
| 50 | + (browser |
| 51 | + ? new URL("/oauth/callback", window.location.origin).toString() |
| 52 | + : "/oauth/callback") |
| 53 | + ); |
| 54 | + this.preventAutoAuth = options.preventAutoAuth; |
| 55 | + this.onPopupWindow = options.onPopupWindow; |
| 56 | + } |
| 57 | + |
| 58 | + get redirectUrl(): string { |
| 59 | + return sanitizeUrl(this.callbackUrl); |
| 60 | + } |
| 61 | + |
| 62 | + get clientMetadata(): OAuthClientMetadata { |
| 63 | + return { |
| 64 | + redirect_uris: [this.redirectUrl], |
| 65 | + token_endpoint_auth_method: "none", |
| 66 | + grant_types: ["authorization_code", "refresh_token"], |
| 67 | + response_types: ["code"], |
| 68 | + client_name: this.clientName, |
| 69 | + client_uri: this.clientUri, |
| 70 | + }; |
| 71 | + } |
| 72 | + |
| 73 | + async clientInformation(): Promise<OAuthClientInformation | undefined> { |
| 74 | + const data = browser ? localStorage.getItem(this.getKey("client_info")) : null; |
| 75 | + if (!data) return undefined; |
| 76 | + try { |
| 77 | + return JSON.parse(data) as OAuthClientInformation; |
| 78 | + } catch { |
| 79 | + localStorage.removeItem(this.getKey("client_info")); |
| 80 | + return undefined; |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + async saveClientInformation(info: OAuthClientInformation): Promise<void> { |
| 85 | + if (!browser) return; |
| 86 | + localStorage.setItem(this.getKey("client_info"), JSON.stringify(info)); |
| 87 | + } |
| 88 | + |
| 89 | + async tokens(): Promise<OAuthTokens | undefined> { |
| 90 | + const data = browser ? localStorage.getItem(this.getKey("tokens")) : null; |
| 91 | + if (!data) return undefined; |
| 92 | + try { |
| 93 | + return JSON.parse(data) as OAuthTokens; |
| 94 | + } catch { |
| 95 | + localStorage.removeItem(this.getKey("tokens")); |
| 96 | + return undefined; |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + async saveTokens(tokens: OAuthTokens): Promise<void> { |
| 101 | + if (!browser) return; |
| 102 | + localStorage.setItem(this.getKey("tokens"), JSON.stringify(tokens)); |
| 103 | + localStorage.removeItem(this.getKey("code_verifier")); |
| 104 | + localStorage.removeItem(this.getKey("last_auth_url")); |
| 105 | + } |
| 106 | + |
| 107 | + async saveCodeVerifier(verifier: string): Promise<void> { |
| 108 | + if (!browser) return; |
| 109 | + localStorage.setItem(this.getKey("code_verifier"), verifier); |
| 110 | + } |
| 111 | + |
| 112 | + async codeVerifier(): Promise<string> { |
| 113 | + const v = browser ? localStorage.getItem(this.getKey("code_verifier")) : null; |
| 114 | + if (!v) throw new Error("Code verifier not found"); |
| 115 | + return v; |
| 116 | + } |
| 117 | + |
| 118 | + async prepareAuthorizationUrl(authorizationUrl: URL): Promise<string> { |
| 119 | + if (!browser) return authorizationUrl.toString(); |
| 120 | + const state = Math.random().toString(36).slice(2); |
| 121 | + const stateKey = `${this.storageKeyPrefix}:state_${state}`; |
| 122 | + const expiry = Date.now() + 5 * 60 * 1000; |
| 123 | + const stored: StoredState = { |
| 124 | + expiry, |
| 125 | + serverUrlHash: this.serverUrlHash, |
| 126 | + providerOptions: { |
| 127 | + serverUrl: this.serverUrl, |
| 128 | + storageKeyPrefix: this.storageKeyPrefix, |
| 129 | + clientName: this.clientName, |
| 130 | + clientUri: this.clientUri, |
| 131 | + callbackUrl: this.callbackUrl, |
| 132 | + }, |
| 133 | + }; |
| 134 | + localStorage.setItem(stateKey, JSON.stringify(stored)); |
| 135 | + authorizationUrl.searchParams.set("state", state); |
| 136 | + const full = authorizationUrl.toString(); |
| 137 | + const sanitized = sanitizeUrl(full); |
| 138 | + localStorage.setItem(this.getKey("last_auth_url"), sanitized); |
| 139 | + return sanitized; |
| 140 | + } |
| 141 | + |
| 142 | + async redirectToAuthorization(authorizationUrl: URL): Promise<void> { |
| 143 | + if (this.preventAutoAuth || !browser) return; |
| 144 | + const url = await this.prepareAuthorizationUrl(authorizationUrl); |
| 145 | + const features = "width=600,height=700,resizable=yes,scrollbars=yes,status=yes"; |
| 146 | + try { |
| 147 | + const win = window.open(url, `mcp_auth_${this.serverUrlHash}`, features); |
| 148 | + this.onPopupWindow?.(url, features, win); |
| 149 | + if (!win || win.closed) console.warn("[mcp] popup may be blocked"); |
| 150 | + } catch (e) { |
| 151 | + console.error("[mcp] failed to open popup", e); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + getLastAttemptedAuthUrl(): string | null { |
| 156 | + if (!browser) return null; |
| 157 | + const v = localStorage.getItem(this.getKey("last_auth_url")); |
| 158 | + return v ? sanitizeUrl(v) : null; |
| 159 | + } |
| 160 | + |
| 161 | + clearStorage(): number { |
| 162 | + if (!browser) return 0; |
| 163 | + const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}_`; |
| 164 | + const statePrefix = `${this.storageKeyPrefix}:state_`; |
| 165 | + const toRemove: string[] = []; |
| 166 | + for (let i = 0; i < localStorage.length; i++) { |
| 167 | + const k = localStorage.key(i); |
| 168 | + if (!k) continue; |
| 169 | + if (k.startsWith(prefix)) toRemove.push(k); |
| 170 | + if (k.startsWith(statePrefix)) { |
| 171 | + try { |
| 172 | + const raw = localStorage.getItem(k); |
| 173 | + if (!raw) continue; |
| 174 | + const parsed = JSON.parse(raw) as Partial<StoredState>; |
| 175 | + if (parsed.serverUrlHash === this.serverUrlHash) toRemove.push(k); |
| 176 | + } catch {} |
| 177 | + } |
| 178 | + } |
| 179 | + const unique = [...new Set(toRemove)]; |
| 180 | + unique.forEach((k) => localStorage.removeItem(k)); |
| 181 | + return unique.length; |
| 182 | + } |
| 183 | + |
| 184 | + getKey(suffix: string): string { |
| 185 | + return `${this.storageKeyPrefix}_${this.serverUrlHash}_${suffix}`; |
| 186 | + } |
| 187 | + |
| 188 | + private hashString(str: string): string { |
| 189 | + let hash = 0; |
| 190 | + for (let i = 0; i < str.length; i++) { |
| 191 | + hash = (hash << 5) - hash + str.charCodeAt(i); |
| 192 | + hash |= 0; |
| 193 | + } |
| 194 | + return Math.abs(hash).toString(16); |
| 195 | + } |
193 | 196 | } |
0 commit comments