Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: consent card with custom onSubmit #112

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions examples/nextjs-spa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export.
Now you can see Ory Elements in action by opening http://localhost:3000 in your
browser!

#### OAuth2 Login/Consent page

This example provides a working Login/Consent page using the Ory Elements
UserAuthCard and UserConsentCard.

To use the Consent page, the NextJS application will need a Ory API Token set as
an environment variable.

```
export NEXT_ADMIN_ORY_API_KEY=ory_pat_xxxxx
```

The `NEXT_PUBLIC_ORY_SDK_URL` will be used for admin API calls as well since Ory
Network projects expose both endpoint under the same URL.

Take a look at the Ory Documentation to configure your Ory Network project to
use this NextJS application as a custom consent UI.
https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow#consent

### Using and Modifying the Example

If you want to re-use this example in your own project, you can do so by
Expand Down
3 changes: 2 additions & 1 deletion examples/nextjs-spa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"next": "13.4.13",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.2.2"
"typescript": "5.2.2",
"edge-csrf": "1.0.4"
},
"devDependencies": {
"@ory/elements": "*",
Expand Down
193 changes: 193 additions & 0 deletions examples/nextjs-spa/src/app/api/consent/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import { oryIdentity, oryOAuth } from "@/pkg/sdk"
import { redirect } from "next/navigation"
import { NextRequest, NextResponse } from "next/server"

export async function GET(req: NextRequest, res: NextResponse) {
const { searchParams } = new URL(req.url || "")

const consent_challenge = searchParams.get("consent_challenge") || ""

if (!consent_challenge) {
return new NextResponse(
"The consent_challenge is missing from the request",
{
status: 400,
},
).json()
}

return oryOAuth
.getOAuth2ConsentRequest({
consentChallenge: consent_challenge.toString(),
})
.then(async ({ data }) => {
if (data.skip || data.client?.skip_consent) {
let grantScope: string[] = data.requested_scope || []
if (!Array.isArray(grantScope)) {
grantScope = [grantScope]
}

const id_token: { [key: string]: any } = {}

if (data.subject && grantScope.length > 0) {
const identity = (await oryIdentity.getIdentity({ id: data.subject }))
.data

if (grantScope.indexOf("email") > -1) {
// Client may check email of user
id_token.email = identity.traits["email"] || ""
}
if (grantScope.indexOf("phone") > -1) {
// Client may check phone number of user
id_token.phone = identity.traits["phone"] || ""
}
}

const session = {
access_token: {
// foo: 'bar'
},

// This data will be available in the ID token.
id_token,
}
// Now it's time to grant the consent request. You could also deny the request if something went terribly wrong
return oryOAuth
.acceptOAuth2ConsentRequest({
consentChallenge: consent_challenge.toString(),
acceptOAuth2ConsentRequest: {
// We can grant all scopes that have been requested - hydra already checked for us that no additional scopes
// are requested accidentally.
grant_scope: grantScope,

// ORY Hydra checks if requested audiences are allowed by the client, so we can simply echo this.
grant_access_token_audience: data.requested_access_token_audience,

// The session allows us to set session data for id and access tokens
session,
},
})
.then(({ data: body }) => {
// All we need to do now is to redirect the user back to hydra!
return redirect(String(body.redirect_to))
})
.catch((err) => {
console.error(err)
return new NextResponse(err.response.data, {
status: 500,
}).json()
})
} else {
return NextResponse.json({
consent: data,
// WARNING!! ===> Don't use this in production <===
csrf_token: Math.random().toString(36).slice(2),
})
}
})
.catch((err) => {
console.error(err)
return new NextResponse(err.response.data, {
status: 500,
}).json()
})
}

export async function POST(req: NextRequest, res: NextResponse) {
// The challenge is a hidden input field, so we have to retrieve it from the request body
const body = await req.json()
const challenge = body.consent_challenge

// Let's see if the user decided to accept or reject the consent request..
if (body.consent_action === "reject") {
try {
const resp = await oryOAuth.rejectOAuth2ConsentRequest({
consentChallenge: challenge,
rejectOAuth2Request: {
error: "access_denied",
error_description: "The resource owner denied the request",
},
})

return redirect(String(resp.data.redirect_to))
} catch (err) {
console.error(err)
return new NextResponse(
`Something went wrong when rejecting the consent request ${err}`,
{
status: 500,
},
)
}
}

let grantScope = body.grant_scope
if (!Array.isArray(grantScope)) {
grantScope = [grantScope]
}

try {
let id_token: { [key: string]: any } = {}

const consentResp = await oryOAuth
.getOAuth2ConsentRequest({ consentChallenge: challenge })
// This will be called if the HTTP request was successful
.then(async ({ data }) => {
const id_token: { [key: string]: any } = {}

if (data.subject && grantScope.length > 0) {
const identity = (await oryIdentity.getIdentity({ id: data.subject }))
.data

if (grantScope.indexOf("email") > -1) {
// Client may check email of user
id_token.email = identity.traits["email"] || ""
}
if (grantScope.indexOf("phone") > -1) {
// Client may check phone number of user
id_token.phone = identity.traits["phone"] || ""
}
}
return data
})

const acceptResp = await oryOAuth.acceptOAuth2ConsentRequest({
consentChallenge: challenge,
acceptOAuth2ConsentRequest: {
// We can grant all scopes that have been requested - hydra already checked for us that no additional scopes
// are requested accidentally.
grant_scope: grantScope,

session: {
id_token,
access_token: {},
},

// ORY Hydra checks if requested audiences are allowed by the client, so we can simply echo this.
grant_access_token_audience:
consentResp.requested_access_token_audience,

// This tells hydra to remember this consent request and allow the same client to request the same
// scopes from the same user, without showing the UI, in the future.
remember: Boolean(body.remember),

// When this "remember" sesion expires, in seconds. Set this to 0 so it will never expire.
remember_for: process.env.REMEMBER_CONSENT_FOR_SECONDS
? Number(process.env.REMEMBER_CONSENT_SESSION_FOR_SECONDS)
: 3600,
},
})
return NextResponse.redirect(acceptResp.data.redirect_to)
} catch (err) {
console.error(err)
return new NextResponse(
`Something went wrong when accepting the consent request ${err}`,
{
status: 500,
},
)
}
}
31 changes: 31 additions & 0 deletions examples/nextjs-spa/src/app/api/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import csrf from "edge-csrf"
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

// initalize protection function
const csrfProtect = csrf({
cookie: {
secure: process.env.NODE_ENV === "production",
// CHANGE ME
name: "_elements_nextjs_csrf",
// CHANGE ME
sameSite: "lax",
},
})

export async function middleware(request: NextRequest) {
const response = NextResponse.next()

// csrf protection
const csrfError = await csrfProtect(request, response)

// check result
if (csrfError) {
return new NextResponse("invalid csrf token", { status: 403 })
}

return response
}
44 changes: 44 additions & 0 deletions examples/nextjs-spa/src/app/consent/consent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client"

import { OAuth2ConsentRequest } from "@ory/client"
import {
ConsentFormPayload,
CustomOnSubmitCallback,
UserConsentCard,
} from "@ory/elements"

type ConsentProps = {
consent: OAuth2ConsentRequest
csrf_token: string
}

const Consent = ({ consent, csrf_token }: ConsentProps) => {
const onSubmit: CustomOnSubmitCallback<ConsentFormPayload> = ({ body }) => {
fetch(`/api/consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(body),
})
.then((response) => {
console.log({ response })
if (response.redirected) {
window.location.href = response.url
return
}
})
.catch((err) => console.error(err))
}
return (
<UserConsentCard
cardImage="/ory.svg"
consent={consent}
csrfToken={csrf_token}
action={"/consent"}
client_name={consent.client?.client_name || "unknown client"}
requested_scope={consent.requested_scope}
onSubmit={onSubmit}
/>
)
}

export default Consent
37 changes: 37 additions & 0 deletions examples/nextjs-spa/src/app/consent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useSearchParams } from "next/navigation"
import Consent from "./consent"

/**
* This is a server-side component that fetches the consent challenge data for us from Ory.
**/

export default async function Page({
searchParams,
}: {
searchParams: {
[key: string]: string | string[] | undefined
}
}) {
const consent_challenge = searchParams.consent_challenge || ""

let data: Response

try {
data = await fetch(
`http://localhost:3000/api/consent?consent_challenge=${consent_challenge}`,
{
method: "GET",
},
)
} catch (err) {
throw Error(`Unable to fetch consent challenge data: ${err}`)
}

if (data.status !== 200) {
throw Error(`Unable to fetch consent challenge data: ${data.status}`)
}

const { consent, csrf_token } = await data.json()

return <Consent consent={consent} csrf_token={csrf_token} />
}
37 changes: 37 additions & 0 deletions examples/nextjs-spa/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client"
// optional global css reset
import "@ory/elements/assets/normalize.css"

// Import CSS
import "@/styles/globals.css"

// Ory Elements
// optional fontawesome icons
import "@ory/elements/assets/fa-brands.min.css"
import "@ory/elements/assets/fa-solid.min.css"
import "@ory/elements/assets/fontawesome.min.css"

// optional fonts
import "@ory/elements/assets/inter-font.css"
import "@ory/elements/assets/jetbrains-mono-font.css"

// required styles for Ory Elements
import "@ory/elements/style.css"

import { ThemeProvider } from "@ory/elements"

export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
Loading