1import { useCallback, useMemo, useEffect, useState } from 'react';
2
3import { AuthRequest } from './AuthRequest';
4import { AuthRequestConfig, AuthRequestPromptOptions } from './AuthRequest.types';
5import { AuthSessionResult } from './AuthSession.types';
6import { DiscoveryDocument, IssuerOrDiscovery, resolveDiscoveryAsync } from './Discovery';
7
8// @needsAudit
9/**
10 * Given an OpenID Connect issuer URL, this will fetch and return the [`DiscoveryDocument`](#discoverydocument)
11 * (a collection of URLs) from the resource provider.
12 *
13 * @param issuerOrDiscovery URL using the `https` scheme with no query or fragment component that the OP asserts as its Issuer Identifier.
14 * @return Returns `null` until the [`DiscoveryDocument`](#discoverydocument) has been fetched from the provided issuer URL.
15 *
16 * @example
17 * ```ts
18 * const discovery = useAutoDiscovery('https://example.com/auth');
19 * ```
20 */
21export function useAutoDiscovery(issuerOrDiscovery: IssuerOrDiscovery): DiscoveryDocument | null {
22  const [discovery, setDiscovery] = useState<DiscoveryDocument | null>(null);
23
24  useEffect(() => {
25    let isAllowed = true;
26    resolveDiscoveryAsync(issuerOrDiscovery).then((discovery) => {
27      if (isAllowed) {
28        setDiscovery(discovery);
29      }
30    });
31
32    return () => {
33      isAllowed = false;
34    };
35  }, [issuerOrDiscovery]);
36
37  return discovery;
38}
39
40export function useLoadedAuthRequest(
41  config: AuthRequestConfig,
42  discovery: DiscoveryDocument | null,
43  AuthRequestInstance: typeof AuthRequest
44): AuthRequest | null {
45  const [request, setRequest] = useState<AuthRequest | null>(null);
46  const scopeString = useMemo(() => config.scopes?.join(','), [config.scopes]);
47  const extraParamsString = useMemo(
48    () => JSON.stringify(config.extraParams || {}),
49    [config.extraParams]
50  );
51  useEffect(() => {
52    let isMounted = true;
53
54    if (discovery) {
55      const request = new AuthRequestInstance(config);
56      request.makeAuthUrlAsync(discovery).then(() => {
57        if (isMounted) {
58          setRequest(request);
59        }
60      });
61    }
62    return () => {
63      isMounted = false;
64    };
65  }, [
66    discovery?.authorizationEndpoint,
67    config.clientId,
68    config.redirectUri,
69    config.responseType,
70    config.prompt,
71    config.clientSecret,
72    config.codeChallenge,
73    config.state,
74    config.usePKCE,
75    scopeString,
76    extraParamsString,
77  ]);
78  return request;
79}
80
81type PromptMethod = (options?: AuthRequestPromptOptions) => Promise<AuthSessionResult>;
82
83export function useAuthRequestResult(
84  request: AuthRequest | null,
85  discovery: DiscoveryDocument | null,
86  customOptions: AuthRequestPromptOptions = {}
87): [AuthSessionResult | null, PromptMethod] {
88  const [result, setResult] = useState<AuthSessionResult | null>(null);
89
90  const promptAsync = useCallback(
91    async ({ windowFeatures = {}, ...options }: AuthRequestPromptOptions = {}) => {
92      if (!discovery || !request) {
93        throw new Error('Cannot prompt to authenticate until the request has finished loading.');
94      }
95      const inputOptions = {
96        ...customOptions,
97        ...options,
98        windowFeatures: {
99          ...(customOptions.windowFeatures ?? {}),
100          ...windowFeatures,
101        },
102      };
103      const result = await request?.promptAsync(discovery, inputOptions);
104      setResult(result);
105      return result;
106    },
107    [request?.url, discovery?.authorizationEndpoint]
108  );
109
110  return [result, promptAsync];
111}
112
113// @needsAudit
114/**
115 * Load an authorization request for a code. When the prompt method completes then the response will be fulfilled.
116 *
117 * > In order to close the popup window on web, you need to invoke `WebBrowser.maybeCompleteAuthSession()`.
118 * > See the [Identity example](/guides/authentication#identityserver-4) for more info.
119 *
120 * If an Implicit grant flow was used, you can pass the `response.params` to `TokenResponse.fromQueryParams()`
121 * to get a `TokenResponse` instance which you can use to easily refresh the token.
122 *
123 * @param config A valid [`AuthRequestConfig`](#authrequestconfig) that specifies what provider to use.
124 * @param discovery A loaded [`DiscoveryDocument`](#discoverydocument) with endpoints used for authenticating.
125 * Only `authorizationEndpoint` is required for requesting an authorization code.
126 *
127 * @return Returns a loaded request, a response, and a prompt method in a single array in the following order:
128 * - `request` - An instance of [`AuthRequest`](#authrequest) that can be used to prompt the user for authorization.
129 *   This will be `null` until the auth request has finished loading.
130 * - `response` - This is `null` until `promptAsync` has been invoked. Once fulfilled it will return information about the authorization.
131 * - `promptAsync` - When invoked, a web browser will open up and prompt the user for authentication.
132 *   Accepts an [`AuthRequestPromptOptions`](#authrequestpromptoptions) object with options about how the prompt will execute.
133 *
134 * @example
135 * ```ts
136 * const [request, response, promptAsync] = useAuthRequest({ ... }, { ... });
137 * ```
138 */
139export function useAuthRequest(
140  config: AuthRequestConfig,
141  discovery: DiscoveryDocument | null
142): [
143  AuthRequest | null,
144  AuthSessionResult | null,
145  (options?: AuthRequestPromptOptions) => Promise<AuthSessionResult>,
146] {
147  const request = useLoadedAuthRequest(config, discovery, AuthRequest);
148  const [result, promptAsync] = useAuthRequestResult(request, discovery);
149  return [request, result, promptAsync];
150}
151