From f59254b65334d5b17e07661c35780a45f2199884 Mon Sep 17 00:00:00 2001 From: Nicolas Burtey Date: Sun, 23 Jul 2023 11:55:21 +0100 Subject: [PATCH] feat: hydra integration --- dev/ory/hydra.yml | 22 ++++ dev/ory/oathkeeper.yml | 9 +- dev/ory/oathkeeper_rules.yaml | 6 + docker-compose.override.yml | 4 + docker-compose.yml | 40 +++++++ docs/hydra.md | 89 ++++++++++++++ src/app/authentication/login.ts | 15 ++- src/graphql/admin/schema.graphql | 1 + src/graphql/main/schema.graphql | 1 + src/graphql/root/mutation/user-login.ts | 10 +- src/graphql/types/payload/auth-token.ts | 3 + test/e2e/generated.ts | 147 +++--------------------- 12 files changed, 209 insertions(+), 138 deletions(-) create mode 100644 dev/ory/hydra.yml create mode 100644 docs/hydra.md diff --git a/dev/ory/hydra.yml b/dev/ory/hydra.yml new file mode 100644 index 00000000000..8d69cc1d243 --- /dev/null +++ b/dev/ory/hydra.yml @@ -0,0 +1,22 @@ +serve: + cookies: + same_site_mode: Lax + +urls: + self: + issuer: http://127.0.0.1:4444 + consent: http://127.0.0.1:3000/consent + login: http://127.0.0.1:3000/login + logout: http://127.0.0.1:3000/logout + +secrets: + system: + - youReallyNeedToChangeThis + +oidc: + subject_identifiers: + supported_types: + - pairwise + - public + pairwise: + salt: youReallyNeedToChangeThis diff --git a/dev/ory/oathkeeper.yml b/dev/ory/oathkeeper.yml index 9614b8d6d0d..3702490eb3e 100644 --- a/dev/ory/oathkeeper.yml +++ b/dev/ory/oathkeeper.yml @@ -30,6 +30,13 @@ authenticators: subject_from: identity.id extra_from: identity.traits + oauth2_introspection: + enabled: true + config: + introspection_url: http://hydra:4445/admin/oauth2/introspect + token_from: + header: Oauth2-Token + anonymous: enabled: true config: @@ -60,7 +67,7 @@ mutators: config: jwks_url: file:///home/ory/jwks.json issuer_url: "galoy.io" - claims: '{"sub": "{{ print .Subject }}" }' + claims: '{"sub": "{{ print .Subject }}", card: "{{ print .Ext.card }}" }' noop: enabled: true diff --git a/dev/ory/oathkeeper_rules.yaml b/dev/ory/oathkeeper_rules.yaml index 58340b343fb..7c7c2062ae7 100644 --- a/dev/ory/oathkeeper_rules.yaml +++ b/dev/ory/oathkeeper_rules.yaml @@ -62,6 +62,12 @@ preserve_query: true subject_from: identity.id extra_from: identity.traits + - handler: oauth2_introspection + config: + introspection_url: http://hydra:4445/admin/oauth2/introspect + token_from: + header: Oauth2-Token + - handler: bearer_token config: check_session_url: http://kratos:4433/sessions/whoami diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 816d624fa13..5a61a889512 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -90,3 +90,7 @@ services: fulcrum: ports: - "50001:50001" + hydra: + ports: + - "4444:4444" # Public port + - "4445:4445" # Admin port diff --git a/docker-compose.yml b/docker-compose.yml index b1390fe29d6..2b986b1d529 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: - otel-agent - oathkeeper - mailslurper + - hydra + # - consent restart: on-failure:10 integration-deps: image: busybox @@ -402,3 +404,41 @@ services: - SSL_CERTFILE=/tls.cert - SSL_KEYFILE=/tls.key command: ["Fulcrum", "/fulcrum.conf"] + hydra: + image: oryd/hydra:v2.1.2 + command: serve -c /home/ory/hydra.yml all --dev + volumes: + - type: bind + source: dev/ory + target: /home/ory + environment: + - DSN=postgres://hydra:secret@postgresdhydra:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4 + restart: unless-stopped + depends_on: + - hydra-migrate + - postgresdhydra + hydra-migrate: + image: oryd/hydra:v2.1.2 + environment: + - DSN=postgres://hydra:secret@postgresdhydra:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4 + command: migrate -c /home/ory/hydra.yml sql -e --yes + volumes: + - type: bind + source: dev/ory + target: /home/ory + restart: on-failure + depends_on: + - postgresdhydra + # consent: + # environment: + # - HYDRA_ADMIN_URL=http://hydra:4445 + # image: oryd/hydra-login-consent-node:v2.1.2 + # ports: + # - "3000:3000" + # restart: unless-stopped + postgresdhydra: + image: postgres:14.1 + environment: + - POSTGRES_USER=hydra + - POSTGRES_PASSWORD=secret + - POSTGRES_DB=hydra \ No newline at end of file diff --git a/docs/hydra.md b/docs/hydra.md new file mode 100644 index 00000000000..f074ec25623 --- /dev/null +++ b/docs/hydra.md @@ -0,0 +1,89 @@ +// TODO: PKCE flow (for alby like client, or mobile client) +// TODO: login flow with cookie +// TODO: add/use email instead of phone + +Make sure you have `hydra` command line installed + +```sh +brew install ory-hydra +``` + +# run the experiment: + +Follow the instructions below + + +On console 1: + +launch the hydra login consent node, which will provide the authentication (interactive with kratos API) and consent page. + +```sh +hydra-login-consent-node % yarn start +``` + +On console 2: +```sh +galoy % make start-deps +``` + +On console 3: +Follow the instructions below + + +## create a OAuth2 client + +Think of the client as the service that need to get the delegation access + +If you use concourse, you, as the end user, will login with Google Workspace. +The client is concourse in this example. + +For the galoy stack, some examples of clients could be Alby, a boltcard service, a nostr wallet connect service, an accountant that access the wallet in read only. + + +```sh +code_client=$(hydra create client \ + --endpoint http://127.0.0.1:4445 \ + --grant-type authorization_code,refresh_token \ + --response-type code,id_token \ + --format json \ + --scope openid --scope offline \ + --redirect-uri http://127.0.0.1:5555/callback +) + +code_client_id=$(echo $code_client | jq -r '.client_id') +code_client_secret=$(echo $code_client | jq -r '.client_secret') +``` + +to do a PKCE session, add `--token-endpoint-auth-method none` + +## Initiate the request + +this simulate the front end client. +would be mobile app for adding a boltcard + +```sh +hydra perform authorization-code \ + --client-id $code_client_id \ + --client-secret $code_client_secret \ + --endpoint http://127.0.0.1:4444/ \ + --port 5555 \ + --scope openid --scope offline +``` + +do the login and consent + +copy the Access token to the mobile app. + +you are now connect as the user when you add the Header `Oauth2-Token: {token}`. (not that Bearer should not be present, unlike for the Authorization header. seems to a oathkeeper quirks) + +### debug + +hydra introspect token \ + --format json-pretty \ + --endpoint http://127.0.0.1:4445/ \ + TOKEN +# OR +curl -X POST http://localhost:4445/admin/oauth2/introspect -d token=ory_at_TOKEN + +curl -I -X POST http://localhost:4456/decisions/graphql -H 'Authorization: Bearer ory_at_TOKEN' + diff --git a/src/app/authentication/login.ts b/src/app/authentication/login.ts index e9d4676a718..bbc15a79278 100644 --- a/src/app/authentication/login.ts +++ b/src/app/authentication/login.ts @@ -33,6 +33,11 @@ import { rewardFailedLoginAttemptPerLoginIdentifierLimits, } from "./ratelimits" +type LoginWithPhoneTokenResult = { + authToken: SessionToken + id: UserId +} + export const loginWithPhoneToken = async ({ phone, code, @@ -41,7 +46,7 @@ export const loginWithPhoneToken = async ({ phone: PhoneNumber code: PhoneCode ip: IpAddress -}): Promise => { +}): Promise => { { const limitOk = await checkFailedLoginAttemptPerIpLimits(ip) if (limitOk instanceof Error) return limitOk @@ -75,14 +80,18 @@ export const loginWithPhoneToken = async ({ const kratosResult = await authService.createIdentityWithSession({ phone }) if (kratosResult instanceof Error) return kratosResult - return kratosResult.sessionToken + return { authToken: kratosResult.sessionToken, id: kratosResult.kratosUserId } } if (userId instanceof Error) return userId const kratosResult = await authService.loginToken({ phone }) if (kratosResult instanceof Error) return kratosResult - return kratosResult.sessionToken + + // FIXME: kratosResult.kratosUserId will be null when using 2FA + const id = kratosResult.kratosUserId as UserId + + return { authToken: kratosResult.sessionToken, id } } export const loginWithPhoneCookie = async ({ diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index fe519847549..9a37b661b3a 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -56,6 +56,7 @@ scalar AuthToken type AuthTokenPayload { authToken: AuthToken errors: [Error!]! + id: ID! } """ diff --git a/src/graphql/main/schema.graphql b/src/graphql/main/schema.graphql index eb4c63cdbd0..15981bec881 100644 --- a/src/graphql/main/schema.graphql +++ b/src/graphql/main/schema.graphql @@ -84,6 +84,7 @@ scalar AuthToken type AuthTokenPayload { authToken: AuthToken errors: [Error!]! + id: ID! } """ diff --git a/src/graphql/root/mutation/user-login.ts b/src/graphql/root/mutation/user-login.ts index 8b05e4f5f0c..93fc555c80e 100644 --- a/src/graphql/root/mutation/user-login.ts +++ b/src/graphql/root/mutation/user-login.ts @@ -46,17 +46,19 @@ const UserLoginMutation = GT.Field<{ return { errors: [{ message: "ip is undefined" }] } } - const authToken = await Auth.loginWithPhoneToken({ + const res = await Auth.loginWithPhoneToken({ phone, code, ip, }) - if (authToken instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(authToken)] } + if (res instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(res)] } } - return { errors: [], authToken } + const { authToken, id } = res + + return { errors: [], authToken, id } }, }) diff --git a/src/graphql/types/payload/auth-token.ts b/src/graphql/types/payload/auth-token.ts index f24ca3a4f54..0943e62cd84 100644 --- a/src/graphql/types/payload/auth-token.ts +++ b/src/graphql/types/payload/auth-token.ts @@ -12,6 +12,9 @@ const AuthTokenPayload = GT.Object({ authToken: { type: AuthToken, }, + id: { + type: GT.NonNull(GT.ID), + }, }), }) diff --git a/test/e2e/generated.ts b/test/e2e/generated.ts index 635347d9a00..aa0223fdabb 100644 --- a/test/e2e/generated.ts +++ b/test/e2e/generated.ts @@ -29,7 +29,7 @@ export type Scalars = { DisplayCurrency: { input: string; output: string; } /** Email address */ EmailAddress: { input: string; output: string; } - /** An id to be passed between set and verify for confirming email */ + /** An id to be passed between registrationInitiate and registrationValidate for confirming email */ EmailRegistrationId: { input: string; output: string; } /** Feedback shared with our user */ Feedback: { input: string; output: string; } @@ -686,6 +686,7 @@ export type Mutation = { /** @deprecated Use QuizCompletedMutation instead */ readonly userQuizQuestionUpdateCompleted: UserQuizQuestionUpdateCompletedPayload; readonly userRequestAuthCode: SuccessPayload; + readonly userTotpDelete: UserTotpDeletePayload; readonly userTotpRegistrationInitiate: UserTotpRegistrationInitiatePayload; readonly userTotpRegistrationValidate: UserTotpRegistrationValidatePayload; readonly userUpdateLanguage: UserUpdateLanguagePayload; @@ -879,6 +880,11 @@ export type MutationUserRequestAuthCodeArgs = { }; +export type MutationUserTotpDeleteArgs = { + input: UserTotpDeleteInput; +}; + + export type MutationUserTotpRegistrationInitiateArgs = { input: UserTotpRegistrationInitiateInput; }; @@ -1584,6 +1590,16 @@ export type UserRequestAuthCodeInput = { readonly phone: Scalars['Phone']['input']; }; +export type UserTotpDeleteInput = { + readonly authToken: Scalars['AuthToken']['input']; +}; + +export type UserTotpDeletePayload = { + readonly __typename: 'UserTotpDeletePayload'; + readonly errors: ReadonlyArray; + readonly me?: Maybe; +}; + export type UserTotpRegistrationInitiateInput = { readonly authToken: Scalars['AuthToken']['input']; }; @@ -1815,27 +1831,6 @@ export type LnUsdInvoiceCreateMutationVariables = Exact<{ export type LnUsdInvoiceCreateMutation = { readonly __typename: 'Mutation', readonly lnUsdInvoiceCreate: { readonly __typename: 'LnInvoicePayload', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }>, readonly invoice?: { readonly __typename: 'LnInvoice', readonly paymentRequest: string, readonly paymentHash: string, readonly paymentSecret: string } | null } }; -export type LnInvoiceFeeProbeMutationVariables = Exact<{ - input: LnInvoiceFeeProbeInput; -}>; - - -export type LnInvoiceFeeProbeMutation = { readonly __typename: 'Mutation', readonly lnInvoiceFeeProbe: { readonly __typename: 'SatAmountPayload', readonly amount?: number | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }> } }; - -export type LnNoAmountInvoiceFeeProbeMutationVariables = Exact<{ - input: LnNoAmountInvoiceFeeProbeInput; -}>; - - -export type LnNoAmountInvoiceFeeProbeMutation = { readonly __typename: 'Mutation', readonly lnNoAmountInvoiceFeeProbe: { readonly __typename: 'SatAmountPayload', readonly amount?: number | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }> } }; - -export type LnNoAmountInvoicePaymentSendMutationVariables = Exact<{ - input: LnNoAmountInvoicePaymentInput; -}>; - - -export type LnNoAmountInvoicePaymentSendMutation = { readonly __typename: 'Mutation', readonly lnNoAmountInvoicePaymentSend: { readonly __typename: 'PaymentSendPayload', readonly status?: PaymentSendResult | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }> } }; - export type MyUpdatesSubscriptionVariables = Exact<{ [key: string]: never; }>; @@ -2773,114 +2768,6 @@ export function useLnUsdInvoiceCreateMutation(baseOptions?: Apollo.MutationHookO export type LnUsdInvoiceCreateMutationHookResult = ReturnType; export type LnUsdInvoiceCreateMutationResult = Apollo.MutationResult; export type LnUsdInvoiceCreateMutationOptions = Apollo.BaseMutationOptions; -export const LnInvoiceFeeProbeDocument = gql` - mutation LnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) { - lnInvoiceFeeProbe(input: $input) { - errors { - message - } - amount - } -} - `; -export type LnInvoiceFeeProbeMutationFn = Apollo.MutationFunction; - -/** - * __useLnInvoiceFeeProbeMutation__ - * - * To run a mutation, you first call `useLnInvoiceFeeProbeMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useLnInvoiceFeeProbeMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [lnInvoiceFeeProbeMutation, { data, loading, error }] = useLnInvoiceFeeProbeMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useLnInvoiceFeeProbeMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(LnInvoiceFeeProbeDocument, options); - } -export type LnInvoiceFeeProbeMutationHookResult = ReturnType; -export type LnInvoiceFeeProbeMutationResult = Apollo.MutationResult; -export type LnInvoiceFeeProbeMutationOptions = Apollo.BaseMutationOptions; -export const LnNoAmountInvoiceFeeProbeDocument = gql` - mutation LnNoAmountInvoiceFeeProbe($input: LnNoAmountInvoiceFeeProbeInput!) { - lnNoAmountInvoiceFeeProbe(input: $input) { - errors { - message - } - amount - } -} - `; -export type LnNoAmountInvoiceFeeProbeMutationFn = Apollo.MutationFunction; - -/** - * __useLnNoAmountInvoiceFeeProbeMutation__ - * - * To run a mutation, you first call `useLnNoAmountInvoiceFeeProbeMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useLnNoAmountInvoiceFeeProbeMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [lnNoAmountInvoiceFeeProbeMutation, { data, loading, error }] = useLnNoAmountInvoiceFeeProbeMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useLnNoAmountInvoiceFeeProbeMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(LnNoAmountInvoiceFeeProbeDocument, options); - } -export type LnNoAmountInvoiceFeeProbeMutationHookResult = ReturnType; -export type LnNoAmountInvoiceFeeProbeMutationResult = Apollo.MutationResult; -export type LnNoAmountInvoiceFeeProbeMutationOptions = Apollo.BaseMutationOptions; -export const LnNoAmountInvoicePaymentSendDocument = gql` - mutation LnNoAmountInvoicePaymentSend($input: LnNoAmountInvoicePaymentInput!) { - lnNoAmountInvoicePaymentSend(input: $input) { - errors { - message - } - status - } -} - `; -export type LnNoAmountInvoicePaymentSendMutationFn = Apollo.MutationFunction; - -/** - * __useLnNoAmountInvoicePaymentSendMutation__ - * - * To run a mutation, you first call `useLnNoAmountInvoicePaymentSendMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useLnNoAmountInvoicePaymentSendMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [lnNoAmountInvoicePaymentSendMutation, { data, loading, error }] = useLnNoAmountInvoicePaymentSendMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useLnNoAmountInvoicePaymentSendMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(LnNoAmountInvoicePaymentSendDocument, options); - } -export type LnNoAmountInvoicePaymentSendMutationHookResult = ReturnType; -export type LnNoAmountInvoicePaymentSendMutationResult = Apollo.MutationResult; -export type LnNoAmountInvoicePaymentSendMutationOptions = Apollo.BaseMutationOptions; export const MyUpdatesDocument = gql` subscription myUpdates { myUpdates {