diff --git a/code-examples/sdk/typescript/src/oauth2/consent-verbose.ts b/code-examples/sdk/typescript/src/oauth2/consent-verbose.ts index df0e63bc6..b741500e7 100644 --- a/code-examples/sdk/typescript/src/oauth2/consent-verbose.ts +++ b/code-examples/sdk/typescript/src/oauth2/consent-verbose.ts @@ -1,85 +1,272 @@ -// Copyright © 2022 Ory Corp +// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { + AcceptOAuth2ConsentRequestSession, + IdentityApi, + OAuth2ConsentRequest, +} from "@ory/client" +import { UserConsentCard } from "@ory/elements-markup" +import bodyParser from "body-parser" +import csrf from "csurf" +import { defaultConfig, RouteCreator, RouteRegistrator } from "../pkg" +import { register404Route } from "./404" +import { oidcConformityMaybeFakeSession } from "./stub/oidc-cert" -import { Configuration, OAuth2Api } from "@ory/client" -import { Request, Response } from "express" +async function createOAuth2ConsentRequestSession( + grantScopes: string[], + consentRequest: OAuth2ConsentRequest, + identityApi: IdentityApi, +): Promise { + // The session allows us to set session data for id and access tokens -const ory = new OAuth2Api( - new Configuration({ - basePath: `https://${process.env.ORY_PROJECT_SLUG}.projects.oryapis.com`, - accessToken: process.env.ORY_API_KEY, - }), -) + const id_token: { [key: string]: any } = {} -function authenticateUserCredentials(email: string, password: string): any { - // Example method to authenticate users and fetch them from the DB. + if (consentRequest.subject && grantScopes.length > 0) { + const identity = ( + await identityApi.getIdentity({ id: consentRequest.subject }) + ).data + + if (grantScopes.indexOf("email") > -1) { + // Client may check email of user + id_token.email = identity.traits["email"] || "" + } + if (grantScopes.indexOf("phone") > -1) { + // Client may check phone number of user + id_token.phone = identity.traits["phone"] || "" + } + } + + return { + // This data will be available when introspecting the token. Try to avoid sensitive information here, + // unless you limit who can introspect tokens. + access_token: { + // foo: 'bar' + }, + + // This data will be available in the ID token. + id_token, + } } -// Please note that this is an example implementation. -// In a production app, please add proper error handling. -export async function handleLogin(request: Request, response: Response) { - const challenge = request.query.login_challenge.toString() - const { data: loginRequest } = await ory.getOAuth2LoginRequest({ - loginChallenge: challenge.toString(), - }) - - if (loginRequest.skip) { - // User is already authenticated, don't show the login form and simply accept the login request. - await ory - .acceptOAuth2LoginRequest({ - loginChallenge: challenge, - acceptOAuth2LoginRequest: { - subject: loginRequest.subject, - }, +// A simple express handler that shows the Hydra consent screen. +export const createConsentRoute: RouteCreator = + (createHelpers) => (req, res, next) => { + console.log("createConsentRoute") + res.locals.projectName = "An application requests access to your data!" + + const { oauth2, identity } = createHelpers(req, res) + const { consent_challenge } = req.query + + // The challenge is used to fetch information about the consent request from ORY hydraAdmin. + const challenge = String(consent_challenge) + if (!challenge) { + next( + new Error("Expected a consent challenge to be set but received none."), + ) + return + } + + let trustedClients: string[] = [] + if (process.env.TRUSTED_CLIENT_IDS) { + trustedClients = String(process.env.TRUSTED_CLIENT_IDS).split(",") + } + + console.log("getOAuth2ConsentRequest", challenge) + // This section processes consent requests and either shows the consent UI or + // accepts the consent request right away if the user has given consent to this + // app before + oauth2 + .getOAuth2ConsentRequest({ consentChallenge: challenge }) + // This will be called if the HTTP request was successful + .then(async ({ data: body }) => { + // If a user has granted this application the requested scope, hydra will tell us to not show the UI. + if ( + body.skip || + body.client?.skip_consent || + (body.client?.client_id && + trustedClients.indexOf(body.client?.client_id) > -1) + ) { + // You can apply logic here, for example grant another scope, or do whatever... + // ... + + let grantScope: string[] = body.requested_scope || [] + if (!Array.isArray(grantScope)) { + grantScope = [grantScope] + } + const session = await createOAuth2ConsentRequestSession( + grantScope, + body, + identity, + ) + + // Now it's time to grant the consent request. You could also deny the request if something went terribly wrong + return oauth2 + .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, + + // ORY Hydra checks if requested audiences are allowed by the client, so we can simply echo this. + grant_access_token_audience: + body.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! + res.redirect(String(body.redirect_to)) + }) + } + + // If consent can't be skipped we MUST show the consent UI. + res.render("consent", { + card: UserConsentCard({ + consent: body, + csrfToken: req.csrfToken(), + cardImage: body.client?.logo_uri || "/ory-logo.svg", + client_name: body.client?.client_name || "unknown client", + requested_scope: body.requested_scope, + client: body.client, + action: (process.env.BASE_URL || "") + "/consent", + }), + }) }) - .then(({ data }) => response.redirect(data.redirect_to)) - return + // This will handle any error that happens when making HTTP calls to hydra + .catch(next) + // The consent request has now either been accepted automatically or rendered. } - // Show the login form if the form was not submitted. - if (request.method === "GET") { - response.render("login", { - loginRequest, - }) - return - } +export const createConsentPostRoute: RouteCreator = + (createHelpers) => (req, res, next) => { + // The challenge is a hidden input field, so we have to retrieve it from the request body + const challenge = req.body.consent_challenge + const { oauth2, identity } = createHelpers(req, res) + + // Let's see if the user decided to accept or reject the consent request.. + if (req.body.submit === "Deny access") { + // Looks like the consent request was denied by the user + return ( + oauth2 + .rejectOAuth2ConsentRequest({ + consentChallenge: challenge, + rejectOAuth2Request: { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + }) + .then(({ data: body }) => { + // All we need to do now is to redirect the browser back to hydra! + res.redirect(String(body.redirect_to)) + }) + // This will handle any error that happens when making HTTP calls to hydra + .catch(next) + ) + } + + let grantScope = req.body.grant_scope + if (!Array.isArray(grantScope)) { + grantScope = [grantScope] + } + + // Here is also the place to add data to the ID or access token. For example, + // if the scope 'profile' is added, add the family and given name to the ID Token claims: + // if (grantScope.indexOf('profile')) { + // session.id_token.family_name = 'Doe' + // session.id_token.given_name = 'John' + // } + + // Let's fetch the consent request again to be able to set `grantAccessTokenAudience` properly. + oauth2 + .getOAuth2ConsentRequest({ consentChallenge: challenge }) + // This will be called if the HTTP request was successful + .then(async ({ data: body }) => { + const session = await createOAuth2ConsentRequestSession( + grantScope, + body, + identity, + ) + return oauth2 + .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, - // The user did not want to sign in with the given app. - if (request.body.submit === "Deny access") { - await ory - .rejectOAuth2LoginRequest({ - loginChallenge: challenge, - rejectOAuth2Request: { - error: "access_denied", - error_description: "The resource owner denied the request", - }, + // If the environment variable CONFORMITY_FAKE_CLAIMS is set we are assuming that + // the app is built for the automated OpenID Connect Conformity Test Suite. You + // can peak inside the code for some ideas, but be aware that all data is fake + // and this only exists to fake a login system which works in accordance to OpenID Connect. + // + // If that variable is not set, the session will be used as-is. + session: oidcConformityMaybeFakeSession( + grantScope, + body, + session, + ), + + // ORY Hydra checks if requested audiences are allowed by the client, so we can simply echo this. + grant_access_token_audience: body.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(req.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, + }, + }) + .then(({ data: body }) => { + // All we need to do now is to redirect the user back! + res.redirect(String(body.redirect_to)) + }) }) - .then(({ data }) => response.redirect(data.redirect_to)) + .catch(next) } - const user = authenticateUserCredentials( - request.body.email, - request.body.password, - ) +// Sets up csrf protection +const csrfProtection = csrf({ + cookie: { + sameSite: "lax", + }, +}) - // Check login credentials (e.g. email + password) in your user database. - if (user!) { - response.render("login", { error: "invalid credentials", loginRequest }) - return +var parseForm = bodyParser.urlencoded({ extended: false }) + +export const registerConsentRoute: RouteRegistrator = function ( + app, + createHelpers = defaultConfig, +) { + if (process.env.HYDRA_ADMIN_URL) { + console.log("found HYDRA_ADMIN_URL") + return app.get( + "/consent", + csrfProtection, + createConsentRoute(createHelpers), + ) + } else { + return register404Route } +} - // User was authenticated successfully, - return await ory - .acceptOAuth2LoginRequest({ - loginChallenge: challenge, - acceptOAuth2LoginRequest: { - subject: user.id, - remember: Boolean(request.body.remember), - remember_for: 3600, - context: { - // You can add any context that you want to be available to the consent endpoint. - }, - }, - }) - .then(({ data }) => response.redirect(data.redirect_to)) +export const registerConsentPostRoute: RouteRegistrator = function ( + app, + createHelpers = defaultConfig, +) { + if (process.env.HYDRA_ADMIN_URL) { + return app.post( + "/consent", + parseForm, + csrfProtection, + createConsentPostRoute(createHelpers), + ) + } else { + return register404Route + } }