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  };