go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/data/oauth.tsx (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 16 // OAuthError can be raised by accessToken(). 17 export class OAuthError extends Error { 18 // If true, the user explicitly canceled the login flow. 19 readonly cancelled: boolean; 20 21 constructor(msg: string, cancelled = false) { 22 super(msg); 23 Object.setPrototypeOf(this, OAuthError.prototype); 24 this.cancelled = cancelled; 25 } 26 } 27 28 29 // Account represents a logged in user account. 30 export interface Account { 31 email: string; 32 picture?: string; 33 } 34 35 36 // Knows how to get and refresh OAuth access tokens. 37 export interface TokenClient { 38 // The state of the session, if any. Affects Login/Logout UI. 39 sessionState: Account | 'loggedout' | 'stateless'; 40 // URL to send the browser to to go through the login flow. 41 loginUrl: string | undefined; 42 // URL to send the browser to to go through the logout flow. 43 logoutUrl: string | undefined; 44 // Returns a fresh OAuth access token to use in a request. 45 accessToken: () => Promise<string>; 46 } 47 48 49 // A TokenClient that uses google.accounts.oauth2 to get and refresh tokens. 50 export class OAuthClient { 51 // This client doesn't use sessions. 52 readonly sessionState = 'stateless'; 53 // Login is not supported. 54 readonly loginUrl = undefined; 55 // Logout is not supported. 56 readonly logoutUrl = undefined; 57 58 private tokenClient: google.accounts.oauth2.TokenClient; 59 private cachedToken: { token: string; expiry: number; } | null = null; 60 private waiters: { 61 resolve: (token: string) => void, 62 reject: (error: Error) => void, 63 }[] = []; 64 65 constructor(clientId: string) { 66 this.tokenClient = google.accounts.oauth2.initTokenClient({ 67 client_id: clientId, 68 scope: 'https://www.googleapis.com/auth/userinfo.email', 69 callback: (response) => this.onTokenResponse(response), 70 error_callback: (error) => this.onTokenError(error), 71 // This field is unknown to @types/google.accounts 0.0.4, but we need it 72 // to avoid getting unexpectedly large set of scopes. Adding this field 73 // requires to forcefully type-cast the struct to TokenClientConfig. 74 include_granted_scopes: false, 75 } as google.accounts.oauth2.TokenClientConfig); 76 } 77 78 // Returns an access token, requesting or refreshing it if necessary. 79 // 80 // Can take a significant amount of time, since it waits for the user to 81 // click buttons to sign in etc. 82 async accessToken(): Promise<string> { 83 // If already have a fresh token, just use it. 84 if (this.cachedToken && Date.now() < this.cachedToken.expiry) { 85 return this.cachedToken.token; 86 } 87 88 // Need to refresh. Create a promise that will be resolved when the 89 // refresh is done. 90 const done = new Promise<string>((resolve, reject) => { 91 this.waiters.push({ resolve, reject }); 92 }); 93 94 // If we are the first caller, actually initiate the asynchronous flow. 95 if (this.waiters.length == 1) { 96 this.tokenClient.requestAccessToken(); 97 } 98 99 // Wait for the flow to complete. 100 return await done; 101 } 102 103 private onTokenResponse(response: google.accounts.oauth2.TokenResponse) { 104 if (response.error || response.error_description) { 105 this.rejectAllWaiters( 106 `${response.error}: ${response.error_description}`, 107 false, 108 ); 109 return; 110 } 111 112 // Remove 60s from the lifetime to make sure we never try to use a token 113 // which is very close to expiration. 114 this.cachedToken = { 115 token: response.access_token, 116 expiry: Date.now() + (parseInt(response.expires_in) - 60) * 1000, 117 }; 118 119 // Resolve all waiting promises successfully. 120 const waiters = this.waiters; 121 this.waiters = []; 122 for (const waiter of waiters) { 123 waiter.resolve(this.cachedToken.token); 124 } 125 } 126 127 private onTokenError(error: google.accounts.oauth2.ClientConfigError) { 128 this.rejectAllWaiters( 129 `${error.type}: ${error.message}`, 130 error.type == 'popup_closed', 131 ); 132 } 133 134 private rejectAllWaiters(error: string, cancelled: boolean) { 135 const waiters = this.waiters; 136 this.waiters = []; 137 for (const waiter of waiters) { 138 waiter.reject(new OAuthError(error, cancelled)); 139 } 140 } 141 } 142 143 144 // Response of the auth state endpoint, see StateEndpointResponse in auth.go. 145 interface StateEndpointResponse { 146 identity?: string; 147 email?: string; 148 picture?: string; 149 accessToken?: string; 150 accessTokenExpiresIn?: number; 151 } 152 153 154 // A TokenClient that uses the server's auth state endpoint. 155 export class StateEndpointClient { 156 // Either logged in or not. 157 sessionState: Account | 'loggedout' = 'loggedout'; 158 // URL to send the browser to to go through the login flow. 159 loginUrl: string; 160 // URL to send the browser to to go through the logout flow. 161 logoutUrl: string; 162 163 private stateUrl: string; 164 private cachedToken: { token: string; expiry: number; } | null = null; 165 166 constructor(stateUrl: string, loginUrl: string, logoutUrl: string) { 167 this.stateUrl = stateUrl; 168 this.loginUrl = loginUrl; 169 this.logoutUrl = logoutUrl; 170 } 171 172 async fetchState() { 173 const response = await fetch(this.stateUrl, { 174 credentials: 'same-origin', 175 headers: { 'Accept': 'application/json' }, 176 }); 177 const state = await response.json() as StateEndpointResponse; 178 if (state.identity == 'anonymous:anonymous') { 179 this.sessionState = 'loggedout'; 180 this.cachedToken = null; 181 return; 182 } 183 if (!state.email) { 184 throw new OAuthError('Missing email in the auth state response'); 185 } 186 if (!state.accessToken) { 187 throw new OAuthError('Missing token in the auth state response'); 188 } 189 if (!state.accessTokenExpiresIn) { 190 throw new OAuthError('Missing expiry in the auth state response'); 191 } 192 this.sessionState = { 193 email: state.email, 194 picture: state.picture, 195 }; 196 // Remove 60s from the lifetime to make sure we never try to use a token 197 // which is very close to expiration. 198 this.cachedToken = { 199 token: state.accessToken, 200 expiry: Date.now() + (state.accessTokenExpiresIn - 60) * 1000, 201 }; 202 } 203 204 // Returns an access token, refreshing it if necessary. 205 // 206 // Raises an OAuthError if the user is not logged in. 207 async accessToken(): Promise<string> { 208 // Need a session to get a token. 209 if (this.sessionState == 'loggedout') { 210 throw new OAuthError('Not logged in'); 211 } 212 // If already have a fresh token, just use it. 213 if (this.cachedToken && Date.now() < this.cachedToken.expiry) { 214 return this.cachedToken.token; 215 } 216 // Need to refresh. This may raise OAuthError or change sessionState. 217 await this.fetchState(); 218 // This will be nil if the session disappeared. 219 if (!this.cachedToken) { 220 throw new OAuthError('Not logged in'); 221 } 222 return this.cachedToken.token; 223 } 224 } 225 226 227 // Loads the OAuth config from the server and initializes the token client. 228 export const loadTokenClient = async (): Promise<TokenClient> => { 229 const response = await fetch('/rpcexplorer/config', { 230 credentials: 'omit', 231 headers: { 'Accept': 'application/json' }, 232 }); 233 234 // See rpcexplorer.go for the Go side. 235 interface Config { 236 loginUrl?: string; 237 logoutUrl?: string; 238 authStateUrl?: string; 239 clientId?: string; 240 } 241 const cfg = await response.json() as Config; 242 243 // If authStateUrl is set, so should be loginUrl and logoutUrl. Use a client 244 // that knows how to grab tokens from the state URL. 245 if (cfg.authStateUrl) { 246 if (!cfg.loginUrl) { 247 throw new Error('loginUrl in the config is empty'); 248 } 249 if (!cfg.logoutUrl) { 250 throw new Error('logoutUrl in the config is empty'); 251 } 252 const client = new StateEndpointClient( 253 cfg.authStateUrl, cfg.loginUrl, cfg.logoutUrl, 254 ); 255 // Check if there's a session already. 256 await client.fetchState(); 257 return client; 258 } 259 260 // A client that uses google.accounts.oauth2 Javascript library. 261 if (!cfg.clientId) { 262 throw new Error('clientId in the config is empty'); 263 } 264 return new OAuthClient(cfg.clientId); 265 };