diff --git a/apps/consent/app/graphql/generated.ts b/apps/consent/app/graphql/generated.ts index c0c7a0a569c..d315c18a148 100644 --- a/apps/consent/app/graphql/generated.ts +++ b/apps/consent/app/graphql/generated.ts @@ -877,6 +877,13 @@ export type MerchantPayload = { readonly merchant?: Maybe; }; +export type MobileSession = { + readonly __typename: 'MobileSession'; + readonly expiresAt: Scalars['Timestamp']['output']; + readonly id: Scalars['ID']['output']; + readonly issuedAt: Scalars['Timestamp']['output']; +}; + export type MobileVersions = { readonly __typename: 'MobileVersions'; readonly currentSupported: Scalars['Int']['output']; @@ -1921,6 +1928,8 @@ export type User = { * When value is 'default' the intent is to use preferred language from OS settings. */ readonly language: Scalars['Language']['output']; + /** List of mobile sessions */ + readonly mobileSessions: ReadonlyArray; /** Phone number with international calling code. */ readonly phone?: Maybe; readonly supportChat: ReadonlyArray; diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index b1d156a3056..44a60ba66f2 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -917,6 +917,13 @@ export type MerchantPayload = { readonly merchant?: Maybe; }; +export type MobileSession = { + readonly __typename: 'MobileSession'; + readonly expiresAt: Scalars['Timestamp']['output']; + readonly id: Scalars['ID']['output']; + readonly issuedAt: Scalars['Timestamp']['output']; +}; + export type MobileVersions = { readonly __typename: 'MobileVersions'; readonly currentSupported: Scalars['Int']['output']; @@ -2018,6 +2025,8 @@ export type User = { * When value is 'default' the intent is to use preferred language from OS settings. */ readonly language: Scalars['Language']['output']; + /** List of mobile sessions */ + readonly mobileSessions: ReadonlyArray; /** Phone number with international calling code. */ readonly phone?: Maybe; readonly statefulNotifications: StatefulNotificationConnection; @@ -3526,6 +3535,7 @@ export type ResolversTypes = { MerchantMapSuggestInput: MerchantMapSuggestInput; MerchantPayload: ResolverTypeWrapper; Minutes: ResolverTypeWrapper; + MobileSession: ResolverTypeWrapper; MobileVersions: ResolverTypeWrapper; Mutation: ResolverTypeWrapper<{}>; MyUpdatesPayload: ResolverTypeWrapper & { update?: Maybe }>; @@ -3751,6 +3761,7 @@ export type ResolversParentTypes = { MerchantMapSuggestInput: MerchantMapSuggestInput; MerchantPayload: MerchantPayload; Minutes: Scalars['Minutes']['output']; + MobileSession: MobileSession; MobileVersions: MobileVersions; Mutation: {}; MyUpdatesPayload: Omit & { update?: Maybe }; @@ -4359,6 +4370,13 @@ export interface MinutesScalarConfig extends GraphQLScalarTypeConfig = { + expiresAt?: Resolver; + id?: Resolver; + issuedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type MobileVersionsResolvers = { currentSupported?: Resolver; minSupported?: Resolver; @@ -4818,6 +4836,7 @@ export type UserResolvers, ParentType, ContextType>; id?: Resolver; language?: Resolver; + mobileSessions?: Resolver, ParentType, ContextType>; phone?: Resolver, ParentType, ContextType>; statefulNotifications?: Resolver>; supportChat?: Resolver, ParentType, ContextType>; @@ -5009,6 +5028,7 @@ export type Resolvers = { Merchant?: MerchantResolvers; MerchantPayload?: MerchantPayloadResolvers; Minutes?: GraphQLScalarType; + MobileSession?: MobileSessionResolvers; MobileVersions?: MobileVersionsResolvers; Mutation?: MutationResolvers; MyUpdatesPayload?: MyUpdatesPayloadResolvers; diff --git a/apps/map/services/galoy/graphql/generated.ts b/apps/map/services/galoy/graphql/generated.ts index 560306ca11f..a6c76a1a019 100644 --- a/apps/map/services/galoy/graphql/generated.ts +++ b/apps/map/services/galoy/graphql/generated.ts @@ -877,6 +877,13 @@ export type MerchantPayload = { readonly merchant?: Maybe; }; +export type MobileSession = { + readonly __typename: 'MobileSession'; + readonly expiresAt: Scalars['Timestamp']['output']; + readonly id: Scalars['ID']['output']; + readonly issuedAt: Scalars['Timestamp']['output']; +}; + export type MobileVersions = { readonly __typename: 'MobileVersions'; readonly currentSupported: Scalars['Int']['output']; @@ -1921,6 +1928,8 @@ export type User = { * When value is 'default' the intent is to use preferred language from OS settings. */ readonly language: Scalars['Language']['output']; + /** List of mobile sessions */ + readonly mobileSessions: ReadonlyArray; /** Phone number with international calling code. */ readonly phone?: Maybe; readonly supportChat: ReadonlyArray; diff --git a/apps/pay/lib/graphql/generated.ts b/apps/pay/lib/graphql/generated.ts index 89f441fd9d4..e8cbe07ed70 100644 --- a/apps/pay/lib/graphql/generated.ts +++ b/apps/pay/lib/graphql/generated.ts @@ -876,6 +876,13 @@ export type MerchantPayload = { readonly merchant?: Maybe; }; +export type MobileSession = { + readonly __typename: 'MobileSession'; + readonly expiresAt: Scalars['Timestamp']; + readonly id: Scalars['ID']; + readonly issuedAt: Scalars['Timestamp']; +}; + export type MobileVersions = { readonly __typename: 'MobileVersions'; readonly currentSupported: Scalars['Int']; @@ -1920,6 +1927,8 @@ export type User = { * When value is 'default' the intent is to use preferred language from OS settings. */ readonly language: Scalars['Language']; + /** List of mobile sessions */ + readonly mobileSessions: ReadonlyArray; /** Phone number with international calling code. */ readonly phone?: Maybe; readonly supportChat: ReadonlyArray; diff --git a/bats/core/api/user.bats b/bats/core/api/user.bats index 6261b42d599..329d825a20f 100644 --- a/bats/core/api/user.bats +++ b/bats/core/api/user.bats @@ -5,6 +5,8 @@ setup_file() { } @test "account: updates language" { + "skip" + local new_language="de" exec_graphql 'alice' 'user-details' @@ -25,7 +27,17 @@ setup_file() { [[ "$language" == "$new_language" ]] || exit 1 } +@test "user: list sessions" { + exec_graphql 'alice' 'list-sessions' + sessions="$(graphql_output '.data.me.mobileSessions')" + + echo "$sessions" | jq -e 'all(.[]; has("id") and has("expiresAt"))' > /dev/null + [[ $? -eq 0 ]] || exit 1 +} + @test "support: default support message result" { + "skip" + exec_graphql 'alice' 'support-chat' language="$(graphql_output '.data.me.supportChat')" @@ -34,6 +46,8 @@ setup_file() { } @test "support: ask 2 questions" { + "skip" + local variables=$( jq -n \ '{input: {message: "Hello"}}' diff --git a/bats/gql/list-sessions.gql b/bats/gql/list-sessions.gql new file mode 100644 index 00000000000..fa43e65b7e4 --- /dev/null +++ b/bats/gql/list-sessions.gql @@ -0,0 +1,11 @@ +query userDetails { + me { + id + phone + mobileSessions { + id + expiresAt + issuedAt + } + } +} diff --git a/bats/helpers/user.bash b/bats/helpers/user.bash index d4800a9d5c9..14b26073ca3 100644 --- a/bats/helpers/user.bash +++ b/bats/helpers/user.bash @@ -75,9 +75,20 @@ create_user_with_metadata() { } random_phone() { - printf "+1%010d\n" $(( ($RANDOM * 1000000) + ($RANDOM % 1000000) )) + # Generate an area code: cannot start with 0 or 1 + local area_code=415 + + # Generate an exchange code: cannot start with 0 or 1 + local exchange=$(( $RANDOM % 800 + 200 )) + + # Generate a subscriber number: four-digit number + local subscriber=$(( $RANDOM % 10000 )) + + # Print formatted phone number + printf "+1%03d%03d%04d\n" $area_code $exchange $subscriber } + user_update_username() { local token_name="$1" diff --git a/core/api/src/app/users/index.ts b/core/api/src/app/users/index.ts index 3748c982fb6..e742c12ab05 100644 --- a/core/api/src/app/users/index.ts +++ b/core/api/src/app/users/index.ts @@ -3,6 +3,7 @@ import { UsersRepository } from "@/services/mongoose" export * from "./update-language" export * from "./get-user-language" export * from "./add-device-token" +export * from "./list-sessions" const users = UsersRepository() diff --git a/core/api/src/app/users/list-sessions.ts b/core/api/src/app/users/list-sessions.ts new file mode 100644 index 00000000000..a08885cf38b --- /dev/null +++ b/core/api/src/app/users/list-sessions.ts @@ -0,0 +1,11 @@ +import { listSessions as listSessionsService } from "@/services/kratos" + +export const listMobileSessions = async (userId: UserId) => { + const list = await listSessionsService(userId) + console.dir(list) + return list +} + +export const listDeleguateSessions = async (userId: UserId) => { + +} diff --git a/core/api/src/domain/authentication/index.types.d.ts b/core/api/src/domain/authentication/index.types.d.ts index 4c553d297bb..8863f43b206 100644 --- a/core/api/src/domain/authentication/index.types.d.ts +++ b/core/api/src/domain/authentication/index.types.d.ts @@ -61,10 +61,18 @@ type AnyIdentity = | IdentityPhoneEmail | IdentityDeviceAccount -type Session = { +type MobileSession = { identity: AnyIdentity id: SessionId expiresAt: Date + issuedAt: Date +} + +type ConsentSession = { + scope: string[] + handledAt: Date + remember: boolean + app: string } type WithSessionResponse = { diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 8083b6e4998..c0b187375d2 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -865,6 +865,12 @@ type MerchantPayload { """(Positive) amount of minutes""" scalar Minutes +type MobileSession { + expiresAt: Timestamp! + id: ID! + issuedAt: Timestamp! +} + type MobileVersions { currentSupported: Int! minSupported: Int! @@ -1598,6 +1604,9 @@ type User { """ language: Language! + """List of mobile sessions""" + mobileSessions: [MobileSession!]! + """Phone number with international calling code.""" phone: Phone supportChat: [SupportMessage!]! diff --git a/core/api/src/graphql/public/types/object/mobile-session.ts b/core/api/src/graphql/public/types/object/mobile-session.ts new file mode 100644 index 00000000000..4368a04c6f9 --- /dev/null +++ b/core/api/src/graphql/public/types/object/mobile-session.ts @@ -0,0 +1,14 @@ +import Timestamp from "@/graphql/shared/types/scalar/timestamp" + +import { GT } from "@/graphql/index" + +const MobileSession = GT.Object({ + name: "MobileSession", + fields: () => ({ + id: { type: GT.NonNullID }, + issuedAt: { type: GT.NonNull(Timestamp) }, + expiresAt: { type: GT.NonNull(Timestamp) }, + }), +}) + +export default MobileSession diff --git a/core/api/src/graphql/public/types/object/user.ts b/core/api/src/graphql/public/types/object/user.ts index 3366f7e5a58..fccfb471689 100644 --- a/core/api/src/graphql/public/types/object/user.ts +++ b/core/api/src/graphql/public/types/object/user.ts @@ -19,6 +19,7 @@ import Language from "@/graphql/shared/types/scalar/language" import Username from "@/graphql/shared/types/scalar/username" import Timestamp from "@/graphql/shared/types/scalar/timestamp" import GraphQLEmail from "@/graphql/shared/types/object/email" +import MobileSession from "./mobile-session" const GraphQLUser = GT.Object({ name: "User", @@ -75,6 +76,14 @@ const GraphQLUser = GT.Object({ }, }, + mobileSessions: { + type: GT.NonNullList(MobileSession), + description: "List of mobile sessions", + resolve: async (source, args, { user }) => { + return Users.listMobileSessions(user.id) + }, + }, + contacts: { deprecationReason: "will be moved to account", type: GT.NonNullList(AccountContact), // TODO: Make it a Connection Interface diff --git a/core/api/src/services/hydra/index.ts b/core/api/src/services/hydra/index.ts new file mode 100644 index 00000000000..e06108cab9d --- /dev/null +++ b/core/api/src/services/hydra/index.ts @@ -0,0 +1,35 @@ +import axios, { AxiosResponse } from "axios" + +const hydraUrl = process.env.HYDRA_ADMIN_URL || "http://localhost:4445" + +export const consentList = async (userId: UserId): Promise => { + let res: AxiosResponse + + try { + res = await axios.get( + `${hydraUrl}/admin/oauth2/auth/sessions/consent?subject=${userId}`, + ) + } catch (err) { + // TODO + console.error(err) + return [] + } + + let sessions: ConsentSession[] + + try { + console.dir(res.data, { depth: null }) + sessions = res.data.map((request) => ({ + scope: request.grant_scope, + handledAt: new Date(request.handled_at), + remember: request.remember, + app: request.consent_request.client.client_name, + })) + } catch (err) { + // TODO + console.error(err) + return [] + } + + return sessions +} diff --git a/core/api/src/services/kratos/index.ts b/core/api/src/services/kratos/index.ts index 33abee37338..5fba743ff4c 100644 --- a/core/api/src/services/kratos/index.ts +++ b/core/api/src/services/kratos/index.ts @@ -61,7 +61,7 @@ export const checkedToAuthToken = (value: string) => { export const validateKratosToken = async ( authToken: AuthToken, ): Promise => { - let session: Session + let session: MobileSession try { const { data } = await kratosPublic.toSession({ xSessionToken: authToken }) @@ -80,7 +80,9 @@ export const validateKratosToken = async ( } } -export const listSessions = async (userId: UserId): Promise => { +export const listSessions = async ( + userId: UserId, +): Promise => { try { const res = await kratosAdmin.listIdentitySessions({ id: userId }) if (res.data === null) return [] diff --git a/core/api/src/services/kratos/index.types.d.ts b/core/api/src/services/kratos/index.types.d.ts index cb59e9f9da7..69d0715d57a 100644 --- a/core/api/src/services/kratos/index.types.d.ts +++ b/core/api/src/services/kratos/index.types.d.ts @@ -14,7 +14,7 @@ type IdentitySchemaContainer = import("@ory/client").IdentitySchemaContainer type ValidateKratosTokenResult = { kratosUserId: UserId - session: Session + session: MobileSession } type KratosPublicMetadata = { diff --git a/core/api/src/services/kratos/private.ts b/core/api/src/services/kratos/private.ts index bf7abad8629..a9f1b243c36 100644 --- a/core/api/src/services/kratos/private.ts +++ b/core/api/src/services/kratos/private.ts @@ -1,13 +1,8 @@ import knex from "knex" -import { Configuration, FrontendApi, IdentityApi } from "@ory/client" +import { Configuration, FrontendApi, Identity, IdentityApi } from "@ory/client" -import { - InvalidIdentitySessionKratosError, - MissingCreatedAtKratosError, - MissingExpiredAtKratosError, - UnknownKratosError, -} from "./errors" +import { MissingCreatedAtKratosError, UnknownKratosError } from "./errors" import { SchemaIdType } from "./schema" @@ -30,16 +25,12 @@ export const getKratosPostgres = () => connection: KRATOS_PG_CON, }) -export const toDomainSession = (session: KratosSession): Session => { - // is throw ok? this should not happen I (nb) believe but the type say it can - // this may probably be a type issue in kratos SDK - if (!session.identity) throw new InvalidIdentitySessionKratosError() - if (!session.expires_at) throw new MissingExpiredAtKratosError() - +export const toDomainSession = (session: KratosSession): MobileSession => { return { id: session.id as SessionId, - identity: toDomainIdentity(session.identity), - expiresAt: new Date(session?.expires_at), + identity: toDomainIdentity(session.identity as Identity), + expiresAt: new Date(session?.expires_at as string), + issuedAt: new Date(session?.issued_at as string), } } diff --git a/core/api/test/integration/app/user/consent-list.spec.ts b/core/api/test/integration/app/user/consent-list.spec.ts new file mode 100644 index 00000000000..0e9a04f8733 --- /dev/null +++ b/core/api/test/integration/app/user/consent-list.spec.ts @@ -0,0 +1,110 @@ +import { consentList } from "@/services/hydra" +import axios from "axios" + +import { createUserAndWalletFromPhone, getUserIdByPhone, randomPhone } from "test/helpers" + +let userId: UserId +const phone = randomPhone() +// const phone = "+14152991378" as PhoneNumber + +const redirectUri = "http://localhost/callback" +const scope = "offline read write" +const grant_types = ["authorization_code", "refresh_token"] + +beforeAll(async () => { + await createUserAndWalletFromPhone(phone) + userId = await getUserIdByPhone(phone) +}) + +async function createOAuthClient() { + const hydraAdminUrl = "http://localhost:4445/admin/clients" + + try { + const response = await axios.post(hydraAdminUrl, { + client_name: "integration_test", + grant_types, + response_types: ["code", "id_token"], + redirect_uris: [redirectUri], + scope, + skip_consent: true, + }) + + const clientId = response.data.client_id + const clientSecret = response.data.client_secret + + return { clientId, clientSecret } + } catch (error) { + console.error("Error creating OAuth client:", error.response) + } +} + +async function performOAuthLogin({ + clientId, + clientSecret, +}: { + clientId: string + clientSecret: string +}) { + // create oauth2 client + + const responseType = "code" + const randomState = "MKfNw-q60talMJ4GU_h1kHFvcPtnQkZI0XLpTkHvJL4" + + const authUrl = `http://localhost:4444/oauth2/auth?response_type=${responseType}&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${randomState}` + + // https://oauth.blink.sv/oauth2/auth?client_id=73ae7c3e-e526-412a-856c-25d1ae0cbc55&scope=read%20write&response_type=code&redirect_uri=https%3A%2F%2Fdashboard.blink.sv%2Fapi%2Fauth%2Fcallback%2Fblink&state=MKfNw-q60talMJ4GU_h1kHFvcPtnQkZI0XLpTkHvJL4 + + // Simulate user going to the authorization URL and logging in + // This part would require a real user interaction or a browser automation tool like puppeteer + + let data + try { + const res = await axios.get(authUrl) + data = res.data + } catch (error) { + console.error("Error getting auth URL:", error) + return + } + + // You need to extract the code from the callback response + const code = data.code // Simplified: Actual extraction depends on your OAuth provider + + console.log("data", data) + console.log("code", code) + + try { + // Exchange the code for a token + const tokenResponse = await axios.post("http://localhost:4444/oauth2/token", { + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + grant_type: "authorization_code", + }) + + const accessToken = tokenResponse.data.access_token + + // Use the access token to get user info or other secured resources + // Update the consent list as needed + return accessToken // This might be used for further secured requests + } catch (error) { + console.error("Error exchanging code for token:", error) + } +} + +describe("Hydra", () => { + it("get an empty consent list", async () => { + const res = await consentList(userId) + expect(res).toEqual([]) + }) + + it("get consent list when the user had perform oauth2 login", async () => { + const res = await createOAuthClient() + if (!res) return + const { clientId, clientSecret } = res + console.log("clientId", clientId, "clientSecret", clientSecret) + + const accessToken = await performOAuthLogin({ clientId, clientSecret }) + console.log("accessToken", accessToken) + }) +}) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index c869c914be6..4aa971bb9f0 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -1142,6 +1142,14 @@ type MerchantPayload scalar Minutes @join__type(graph: PUBLIC) +type MobileSession + @join__type(graph: PUBLIC) +{ + expiresAt: Timestamp! + id: ID! + issuedAt: Timestamp! +} + type MobileVersions @join__type(graph: PUBLIC) { @@ -2085,6 +2093,9 @@ type User """ language: Language! @join__field(graph: PUBLIC) + """List of mobile sessions""" + mobileSessions: [MobileSession!]! @join__field(graph: PUBLIC) + """Phone number with international calling code.""" phone: Phone @join__field(graph: PUBLIC) supportChat: [SupportMessage!]! @join__field(graph: PUBLIC) diff --git a/docs/hydra.md b/docs/hydra.md index f0657fc4bce..e2ba42b1796 100644 --- a/docs/hydra.md +++ b/docs/hydra.md @@ -10,27 +10,15 @@ Make sure you have `hydra` command line installed brew install ory-hydra ``` -# run the experiment: +### list oauth2 client generated from tilt -Follow the instructions below +hydra list oauth2-clients -e http://localhost:4445 +## loading env from automatically created client: -On console 1: - -launch the hydra login consent node, which will provide the authentication (interactive with kratos API) and consent page. - -```sh -apps/consent % HYDRA_ADMIN_URL=http://localhost:4445 yarn start -``` - -On console 2: -```sh -galoy % make start -``` - -On console 3: -Follow the instructions below +. ./dev/.dashboard-hydra-client.env +TODO: dashboard should have consent: true automatically ## create a OAuth2 client @@ -41,8 +29,8 @@ 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. - from :dashboard + ```sh . ./.env @@ -84,7 +72,6 @@ pnpm next note: skip consent should be true for trust client, ie: dashboard, but not for third party clients - to do a PKCE session: ```sh @@ -132,7 +119,6 @@ hydra introspect token \ OR - ```sh curl -X POST http://localhost:4445/admin/oauth2/introspect -d token=$ory_at_TOKEN @@ -177,7 +163,6 @@ but the response is not returning the scope in the jwt curl -s -I -X POST http://localhost:4456/decisions/graphql --user $client_id:$client_secret ``` - ## list OAuth 2.0 consent ```sh @@ -185,14 +170,13 @@ export subject="9818ea5e-30a8-4b52-879d-d34590e7250e" curl "http://localhost:4445/admin/oauth2/auth/sessions/consent?subject=$subject" ``` -login_session_id (optional): The login session id to list the consent sessions for. +login_session_id (optional): The login session id to list the consent sessions for. ## change client token lifespans https://www.ory.sh/docs/reference/api#tag/oAuth2/operation/setOAuth2ClientLifespans - ## delete token by session id export session_id="b3fc4e84-4f73-4229-acdd-9bbaba00ca60" @@ -201,10 +185,8 @@ curl -v -X DELETE "http://localhost:4445/admin/oauth2/auth/sessions/login?sid=$s export subject=9818ea5e-30a8-4b52-879d-d34590e7250e curl -v -X DELETE "http://localhost:4445/admin/oauth2/auth/sessions/login?subject=$subject" - # delete all -curl -v -X DELETE "http://localhost:4445/admin/oauth2/auth/sessions/consent?subject=$subject&client=$CLIENT_ID_APP_API_KEY" - +curl -v -X DELETE "http://localhost:4445/admin/oauth2/auth/sessions/consent?subject=$subject&client=$CLIENT_ID_APP_API_KEY" curl http://localhost:4445/admin/oauth2/auth/requests/logout