diff --git a/examples/react-spa/README.md b/examples/react-spa/README.md index 93ce3727a..20f2c48f3 100644 --- a/examples/react-spa/README.md +++ b/examples/react-spa/README.md @@ -17,7 +17,7 @@ Ory Elements supports integrating with: - Preact - ExpressJs (experimental) -### Get Started +## Get Started ```shell git clone --depth 1 git@github.com:ory/elements.git @@ -38,8 +38,83 @@ ory tunnel http://localhost:3000 --project --dev The tunnel will now _mirror_ the Ory APIs under `http://localhost:4000` which we have explicity told our React app to use through the `VITE_ORY_SDK_URL` export. -Now you can see Ory Elements in action by opening http://localhost:3000 in your -browser! +Now you can see Ory Elements in action by opening in +your browser! + +### Ory OAuth flows + +Ory Network supports OAuth single sign on (SSO) flows. This is applicable to use +cases where you have multiple first-party applications sharing the same user +pool, e.g. sign in on app A and app B with the same credentials. + +Another use case is sharing user data with third party providers. In this case +you are the provider and allow third party apps to sign in your users without +needing access to their credentials. For example "sign in with Google", you are +Google in this scenario. + + + +You can try it out locally using the following steps: + +1. Create an Ory Network project + +```shell +ory create project --name "TestOAuth" +``` + +2. Create an OAuth Client + +```shell +ory create oauth2-client \ + --project ORY_NETWORK_PROJECT_SLUG_OR_ID \ + --name YOUR_CLIENT_NAME \ + --grant-type authorization_code,refresh_token \ + --response-type code,id_token \ + --scope openid,offline \ + --redirect-uri http://127.0.0.1:5555/callback +``` + +3. Run the Ory CLI tunnel + +```shell +ory tunnel http://localhost:3000 --dev --project +``` + +4. Run the React application + +```shell +export VITE_ORY_SDK_URL=http://localhost:4000 +npm run dev +``` + +5. Perform an OAuth flow + +This is using a test OAuth client, this would be another application / service +or Ory Network project. + +```shell +ory perform authorization-code \ + --client-id ORY_CLIENT_ID \ + --client-secret ORY_CLIENT_SECRET \ + --project ORY_PROJECT_ID \ + --port 5555 \ + --scope openid,offline +``` + +**Please take note to initiate the OAuth flow through the Ory tunnel on + instead of your project slug oryapis URL.** + +For example: + +```diff +- https://.projects.oryapis.com/oauth2/auth ++ http://localhost:4000/oauth2/auth +?audience=&client_id=77e447a8-f0b9-42dc-8d75-676a8ebf5e2e&max_age=0&nonce=mwlotcnpyfytjfwmcxsklnnm&prompt=&redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback&response_type=code&scope=openid+offline&state=hcmykhxolygmattztdednkrs +``` + +The reason for this is so that cookies can be set correctly on localhost. When +deploying to production you will use your own domain attached to your Ory +Network project. ### Using and Modifying the Example diff --git a/examples/react-spa/src/Login.tsx b/examples/react-spa/src/Login.tsx index c1be6e474..9da5e0ff0 100644 --- a/examples/react-spa/src/Login.tsx +++ b/examples/react-spa/src/Login.tsx @@ -4,10 +4,42 @@ import { useCallback, useEffect, useState } from "react" import { useNavigate, useSearchParams } from "react-router-dom" import { sdk, sdkError } from "./sdk" +/** + * Login is a React component that renders the login form using Ory Elements. + * It is used to handle the login flow for a variety of authentication methods + * and authentication levels (e.g. Single-Factor and Two-Factor) + * + * The Login component also handles the OAuth2 login flow (as an OAuth2 provider) + * For more information regarding OAuth2 login, please see the following documentation: + * https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow + * + */ export const Login = (): JSX.Element => { const [flow, setFlow] = useState(null) const [searchParams, setSearchParams] = useSearchParams() + // The aal is set as a query parameter by your Ory project + // aal1 is the default authentication level (Single-Factor) + // aal2 is a query parameter that can be used to request Two-Factor authentication + // https://www.ory.sh/docs/kratos/mfa/overview + const aal2 = searchParams.get("aal2") + + // The login_challenge is a query parameter set by the Ory OAuth2 login flow + // Switching between pages should keep the login_challenge in the URL so that the + // OAuth flow can be completed upon completion of another flow (e.g. Registration). + // https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow + const loginChallenge = searchParams.get("login_challenge") + + // The return_to is a query parameter is set by you when you would like to redirect + // the user back to a specific URL after login is successful + // In most cases it is not necessary to set a return_to if the UI business logic is + // handled by the SPA. + // + // In OAuth flows this value might be ignored in favor of keeping the OAuth flow + // intact between multiple flows (e.g. Login -> Recovery -> Settings -> OAuth2 Consent) + // https://www.ory.sh/docs/oauth2-oidc/identity-provider-integration-settings + const returnTo = searchParams.get("return_to") + const navigate = useNavigate() // Get the flow based on the flowId in the URL (.e.g redirect to this page after flow initialized) @@ -26,12 +58,13 @@ export const Login = (): JSX.Element => { // Create a new login flow const createFlow = () => { - const aal2 = searchParams.get("aal2") sdk - // aal2 is a query parameter that can be used to request Two-Factor authentication - // aal1 is the default authentication level (Single-Factor) - // we always pass refresh (true) on login so that the session can be refreshed when there is already an active session - .createBrowserLoginFlow({ refresh: true, aal: aal2 ? "aal2" : "aal1" }) + .createBrowserLoginFlow({ + refresh: true, + aal: aal2 ? "aal2" : "aal1", + ...(loginChallenge && { loginChallenge: loginChallenge }), + ...(returnTo && { returnTo: returnTo }), + }) // flow contains the form fields and csrf token .then(({ data: flow }) => { // Update URI query params to include flow id @@ -80,8 +113,34 @@ export const Login = (): JSX.Element => { flow={flow} // the login card should allow the user to go to the registration page and the recovery page additionalProps={{ - forgotPasswordURL: "/recovery", - signupURL: "/registration", + forgotPasswordURL: { + handler: () => { + const search = new URLSearchParams() + flow.return_to && search.set("return_to", flow.return_to) + navigate( + { + pathname: "/recovery", + search: search.toString(), + }, + { replace: true }, + ) + }, + }, + signupURL: { + handler: () => { + const search = new URLSearchParams() + flow.return_to && search.set("return_to", flow.return_to) + flow.oauth2_login_challenge && + search.set("login_challenge", flow.oauth2_login_challenge) + navigate( + { + pathname: "/registration", + search: search.toString(), + }, + { replace: true }, + ) + }, + }, }} // we might need webauthn support which requires additional js includeScripts={true} diff --git a/examples/react-spa/src/Recovery.tsx b/examples/react-spa/src/Recovery.tsx index 0aebd7489..9640b8c18 100644 --- a/examples/react-spa/src/Recovery.tsx +++ b/examples/react-spa/src/Recovery.tsx @@ -71,7 +71,18 @@ export const Recovery = () => { // the flow is always required since it contains the UI form elements, UI error messages and csrf token flow={flow} // the recovery form should allow users to navigate to the login page - additionalProps={{ loginURL: "/login" }} + additionalProps={{ + loginURL: { + handler: () => { + navigate( + { + pathname: "/login", + }, + { replace: true }, + ) + }, + }, + }} // submit the form data to Ory onSubmit={({ body }) => submitFlow(body as UpdateRecoveryFlowBody)} /> diff --git a/examples/react-spa/src/Registration.tsx b/examples/react-spa/src/Registration.tsx index 1625dd839..05659d8cc 100644 --- a/examples/react-spa/src/Registration.tsx +++ b/examples/react-spa/src/Registration.tsx @@ -4,10 +4,32 @@ import { useCallback, useEffect, useState } from "react" import { useNavigate, useSearchParams } from "react-router-dom" import { sdk, sdkError } from "./sdk" +/** Registration is a React component that renders the Registration form using Ory Elements. + * It is used to handle the registration flow for a variety of authentication methods. + * + * The Registration component also handles the OAuth2 registration flow (as an OAuth2 provider) + * For more information regarding OAuth2 registration, please see the following documentation: + * https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow + * + */ export const Registration = () => { const [flow, setFlow] = useState(null) const [searchParams, setSearchParams] = useSearchParams() + // The return_to is a query parameter is set by you when you would like to redirect + // the user back to a specific URL after registration is successful + // In most cases it is not necessary to set a return_to if the UI business logic is + // handled by the SPA. + // In OAuth flows this value might be ignored in favor of keeping the OAuth flow + // intact between multiple flows (e.g. Login -> Recovery -> Settings -> OAuth2 Consent) + // https://www.ory.sh/docs/oauth2-oidc/identity-provider-integration-settings + const returnTo = searchParams.get("return_to") + + // The login_challenge is a query parameter set by the Ory OAuth2 registration flow + // Switching between pages should keep the login_challenge in the URL so that the + // OAuth flow can be completed upon completion of another flow (e.g. Login). + const loginChallenge = searchParams.get("login_challenge") + const navigate = useNavigate() // Get the flow based on the flowId in the URL (.e.g redirect to this page after flow initialized) @@ -28,7 +50,10 @@ export const Registration = () => { const createFlow = () => { sdk // we don't need to specify the return_to here since we are building an SPA. In server-side browser flows we would need to specify the return_to - .createBrowserRegistrationFlow() + .createBrowserRegistrationFlow({ + ...(returnTo && { returnTo: returnTo }), + ...(loginChallenge && { loginChallenge: loginChallenge }), + }) .then(({ data: flow }) => { // Update URI query params to include flow id setSearchParams({ ["flow"]: flow.id }) @@ -77,7 +102,18 @@ export const Registration = () => { flow={flow} // the registration card needs a way to navigate to the login page additionalProps={{ - loginURL: "/login", + loginURL: { + handler: () => { + const search = new URLSearchParams() + flow.return_to && search.set("return_to", flow.return_to) + flow.oauth2_login_challenge && + search.set("login_challenge", flow.oauth2_login_challenge) + navigate( + { pathname: "/login", search: search.toString() }, + { replace: true }, + ) + }, + }, }} // include the necessary scripts for webauthn to work includeScripts={true} diff --git a/examples/react-spa/src/Verification.tsx b/examples/react-spa/src/Verification.tsx index 5dcb866df..f6a915a8d 100644 --- a/examples/react-spa/src/Verification.tsx +++ b/examples/react-spa/src/Verification.tsx @@ -75,7 +75,11 @@ export const Verification = (): JSX.Element => { flow={flow} // we want users to be able to go back to the login page from the verification page additionalProps={{ - signupURL: "/registration", + signupURL: { + handler: () => { + navigate({ pathname: "/registration" }, { replace: true }) + }, + }, }} // submit the verification form data to Ory onSubmit={({ body }) => submitFlow(body as UpdateVerificationFlowBody)} diff --git a/src/react-components/button-link.tsx b/src/react-components/button-link.tsx index cdb4c6e43..1fa483b4a 100644 --- a/src/react-components/button-link.tsx +++ b/src/react-components/button-link.tsx @@ -10,14 +10,14 @@ import { } from "../theme/button-link.css" export interface CustomHref { - href: string - handler: (url: string) => void + href?: string + handler: () => void } const isCustomHref = ( href: CustomHref | string | undefined, ): href is CustomHref => { - return href !== undefined && (href as CustomHref).href !== undefined + return href !== undefined && (href as CustomHref).handler !== undefined } export type ButtonLinkProps = { @@ -43,10 +43,10 @@ export const ButtonLink = ({ if (isCustomHref(href)) { linkProps = { ...linkProps, - href: href.href, + href: href.href ?? "", onClick: (e: MouseEvent) => { e.preventDefault() - href.handler(href.href) + href.handler() }, } } else {