diff --git a/client/package-lock.json b/client/package-lock.json index 5adeb6f..f1c0329 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "dependencies": { "@aspida/axios": "^1.14.0", + "@aspida/swr": "^1.14.0", "@aws-amplify/ui-react": "^6.3.0", "@aws-sdk/client-cognito-identity-provider": "^3.645.0", "@fakerjs/word": "^1.0.1", @@ -19,6 +20,7 @@ "next": "^14.2.9", "react": "^18.3.1", "react-dom": "^18.3.1", + "swr": "^2.2.5", "zod": "^3.23.8" }, "devDependencies": { @@ -75,6 +77,15 @@ "axios": "" } }, + "node_modules/@aspida/swr": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@aspida/swr/-/swr-1.14.0.tgz", + "integrity": "sha512-d9xdbI5EqL/tIT5NIMF4OHXeDXcr1NvqLJxcYw9buhhzoBtlsVCuBR+Sih4YTQznFsgKCFylOZbRvCYMJpYmdQ==", + "peerDependencies": { + "aspida": "^1.14.0", + "swr": "" + } + }, "node_modules/@aws-amplify/analytics": { "version": "7.0.47", "resolved": "https://registry.npmjs.org/@aws-amplify/analytics/-/analytics-7.0.47.tgz", @@ -7840,6 +7851,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", diff --git a/client/package.json b/client/package.json index 2bba197..a0afbf2 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@aspida/axios": "^1.14.0", + "@aspida/swr": "^1.14.0", "@aws-amplify/ui-react": "^6.3.0", "@aws-sdk/client-cognito-identity-provider": "^3.645.0", "@fakerjs/word": "^1.0.1", @@ -29,6 +30,7 @@ "next": "^14.2.9", "react": "^18.3.1", "react-dom": "^18.3.1", + "swr": "^2.2.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/client/pages/_app.page.tsx b/client/pages/_app.page.tsx index d9bc716..9d7aad0 100644 --- a/client/pages/_app.page.tsx +++ b/client/pages/_app.page.tsx @@ -33,9 +33,9 @@ function MyApp({ Component, pageProps }: AppProps) { oauth: { domain: 'localhost:5052', scopes: ['openid'], - redirectSignIn: ['http://localhost:5051'], - redirectSignOut: ['http://localhost:5051'], - responseType: 'token', + redirectSignIn: [location.origin], + redirectSignOut: [location.origin], + responseType: 'code', }, }, }, diff --git a/client/pages/index.page.tsx b/client/pages/index.page.tsx index 6c9d42e..3dc31c4 100644 --- a/client/pages/index.page.tsx +++ b/client/pages/index.page.tsx @@ -9,6 +9,8 @@ import { useEffect } from 'react'; import { pagesPath } from 'utils/$path'; import styles from './index.module.css'; +export type OptionalQuery = { code: string; state: string }; + const Home = () => { const { user } = useUser(); const router = useRouter(); diff --git a/client/pages/oauth2/authorize.module.css b/client/pages/oauth2/authorize.module.css index b3fc7df..387fc01 100644 --- a/client/pages/oauth2/authorize.module.css +++ b/client/pages/oauth2/authorize.module.css @@ -4,6 +4,37 @@ margin: 0 auto; } +.userInfo { + display: flex; + gap: 16px; + align-items: center; + padding: 12px 8px; + color: gray; + cursor: pointer; + border-radius: 6px; + transition: 0.2s; +} + +.userInfo:hover { + background: #eee; +} + +.userName { + font-weight: bold; +} + +.userEmail { + font-size: 14px; +} + +.userIcon { + width: 36px; + height: 36px; + background: #aaa; + background-size: cover; + border-radius: 50%; +} + .btn { padding: 6px 16px; font-weight: bold; @@ -14,6 +45,11 @@ border-radius: 6px; } +.desc { + font-size: 14px; + color: gray; +} + .inputLabel { font-size: 14px; color: #777; diff --git a/client/pages/oauth2/authorize.page.tsx b/client/pages/oauth2/authorize.page.tsx index b7ec750..819a591 100644 --- a/client/pages/oauth2/authorize.page.tsx +++ b/client/pages/oauth2/authorize.page.tsx @@ -1,39 +1,61 @@ +import useAspidaSWR from '@aspida/swr'; import type { OAuthConfig } from '@aws-amplify/core'; import word from '@fakerjs/word'; -import { PROVIDER_LIST } from 'common/constants'; +import { APP_NAME, PROVIDER_LIST } from 'common/constants'; import type { MaybeId } from 'common/types/brandedId'; import { Spacer } from 'components/Spacer'; import { useRouter } from 'next/router'; import { useState } from 'react'; +import { apiClient } from 'utils/apiClient'; import { z } from 'zod'; import styles from './authorize.module.css'; export type Query = { redirect_uri: string; - response_type: OAuthConfig['responseType']; client_id: MaybeId['userPoolClient']; identity_provider: string; scope: OAuthConfig['scopes']; state: string; -}; +} & ( + | { response_type: 'code'; code_challenge: string; code_challenge_method: 'plain' | 'S256' } + | { response_type: 'token' } +); -const AddAccount = (props: { provider: string; onBack: () => void }) => { +const AddAccount = (props: { + provider: (typeof PROVIDER_LIST)[number]; + codeChallenge: string; + userPoolClientId: MaybeId['userPoolClient']; + onBack: () => void; +}) => { const [email, setEmail] = useState(''); const [displayName, setDisplayName] = useState(''); const [photoUrl, setPhotoUrl] = useState(''); const data = z .object({ email: z.string().email(), - displayName: z.string(), + name: z.string(), photoUrl: z.literal('').or(z.string().url().optional()), }) - .safeParse({ email, displayName, photoUrl }); + .safeParse({ email, name: displayName, photoUrl }); const setFakeVals = () => { const fakeWord = word({ length: 8 }); setEmail(`${fakeWord}@${props.provider.toLowerCase()}.com`); setDisplayName(fakeWord); }; + const addUser = async () => { + if (!data.success) return; + + await apiClient.public.socialUsers.$post({ + body: { + ...data.data, + photoUrl: photoUrl || undefined, + provider: props.provider, + codeChallenge: props.codeChallenge, + userPoolClientId: props.userPoolClientId, + }, + }); + }; return (
@@ -41,7 +63,12 @@ const AddAccount = (props: { provider: string; onBack: () => void }) => { Auto-generate user information -
+ { + e.preventDefault(); + addUser(); + }} + >
Email
void }) => { ); }; +// eslint-disable-next-line complexity const Authorize = () => { const router = useRouter(); + const userPoolClientId = router.query.client_id as MaybeId['userPoolClient']; + const codeChallenge = router.query.code_challenge as string; const provider = z .enum(PROVIDER_LIST) - .parse((router.query.identity_provider as string).replace(/^.+([A-Z][a-z]+)$/, '$1')); + .parse( + (router.query.identity_provider as string | undefined)?.replace(/^.+([A-Z][a-z]+)$/, '$1') ?? + 'Google', + ); + const { data: users } = useAspidaSWR(apiClient.public.socialUsers, { + query: { userPoolClientId }, + }); const [mode, setMode] = useState<'default' | 'add'>('default'); return (

Sign-in with {provider}

-
No {provider} accounts exist in Magnito.
+ {users && users.length > 0 ? ( + <> +
Please select an existing account or add a new one.
+ + {users.map((user) => ( +
+
attr.name === 'picture') + ? `url(${user.attributes.find((attr) => attr.name === 'picture')?.value})` + : undefined, + }} + /> +
+
{user.name}
+
{user.email}
+
+
+ ))} + + ) : ( +
+ No {provider} accounts exist in {APP_NAME}. +
+ )} {mode === 'default' ? ( ) : ( - setMode('default')} /> + setMode('default')} + /> )}
); diff --git a/server/api/public/socialUsers/controller.ts b/server/api/public/socialUsers/controller.ts index 7c4ad94..4c73dec 100644 --- a/server/api/public/socialUsers/controller.ts +++ b/server/api/public/socialUsers/controller.ts @@ -20,6 +20,7 @@ export default defineController(() => ({ provider: z.enum(PROVIDER_LIST), name: z.string(), email: z.string(), + codeChallenge: z.string(), photoUrl: z.string().optional(), userPoolClientId: brandedId.userPoolClient.maybe, }), diff --git a/server/common/types/user.ts b/server/common/types/user.ts index 047e6e1..8e5255f 100644 --- a/server/common/types/user.ts +++ b/server/common/types/user.ts @@ -25,6 +25,8 @@ export type SocialUserEntity = { provider: (typeof PROVIDER_LIST)[number]; password?: undefined; confirmationCode?: undefined; + authorizationCode: string; + codeChallenge: string; salt?: undefined; verifier?: undefined; refreshToken: string; @@ -45,6 +47,8 @@ export type CognitoUserEntity = { provider?: undefined; password: string; confirmationCode: string; + authorizationCode?: undefined; + codeChallenge?: undefined; salt: string; verifier: string; refreshToken: string; @@ -61,6 +65,7 @@ export type SocialUserCreateVal = { provider: (typeof PROVIDER_LIST)[number]; name: string; email: string; + codeChallenge: string; photoUrl?: string; userPoolClientId: MaybeId['userPoolClient']; }; diff --git a/server/domain/user/model/socialUserMethod.ts b/server/domain/user/model/socialUserMethod.ts index 78dbcc6..e5d3bf0 100644 --- a/server/domain/user/model/socialUserMethod.ts +++ b/server/domain/user/model/socialUserMethod.ts @@ -23,6 +23,8 @@ export const socialUserMethod = { kind: 'social', email: val.email, provider: val.provider, + codeChallenge: val.codeChallenge, + authorizationCode: ulid(), enabled: true, status: 'EXTERNAL_PROVIDER', name: val.name, diff --git a/server/domain/user/repository/toUserEntity.ts b/server/domain/user/repository/toUserEntity.ts index a91d6ea..d2696ba 100644 --- a/server/domain/user/repository/toUserEntity.ts +++ b/server/domain/user/repository/toUserEntity.ts @@ -62,6 +62,8 @@ export const toSocialUserEntity = ( status: z.literal(UserStatusType.EXTERNAL_PROVIDER).parse(prismaUser.status), email: prismaUser.email, provider: z.enum(PROVIDER_LIST).parse(prismaUser.provider), + authorizationCode: z.string().parse(prismaUser.authorizationCode), + codeChallenge: z.string().parse(prismaUser.codeChallenge), refreshToken: prismaUser.refreshToken, userPoolId: brandedId.userPool.entity.parse(prismaUser.userPoolId), attributes: prismaUser.attributes.map( diff --git a/server/domain/user/repository/userCommand.ts b/server/domain/user/repository/userCommand.ts index a5a6eaf..07f49dd 100644 --- a/server/domain/user/repository/userCommand.ts +++ b/server/domain/user/repository/userCommand.ts @@ -20,6 +20,8 @@ export const userCommand = { verifier: user.verifier, refreshToken: user.refreshToken, confirmationCode: user.confirmationCode, + authorizationCode: user.authorizationCode, + codeChallenge: user.codeChallenge, secretBlock: user.challenge?.secretBlock, pubA: user.challenge?.pubA, pubB: user.challenge?.pubB, @@ -40,6 +42,8 @@ export const userCommand = { verifier: user.verifier, refreshToken: user.refreshToken, confirmationCode: user.confirmationCode, + authorizationCode: user.authorizationCode, + codeChallenge: user.codeChallenge, userPoolId: user.userPoolId, attributes: { createMany: { data: user.attributes } }, createdAt: new Date(user.createdTime), diff --git a/server/prisma/migrations/20240914025426_/migration.sql b/server/prisma/migrations/20240914025426_/migration.sql new file mode 100644 index 0000000..51d6190 --- /dev/null +++ b/server/prisma/migrations/20240914025426_/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "authorizationCode" TEXT; +ALTER TABLE "User" ADD COLUMN "codeChallenge" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 88fd6c0..327de4c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -8,27 +8,29 @@ generator client { } model User { - id String @id - kind String? - name String - email String - enabled Boolean - status String - password String? - confirmationCode String? - salt String? - verifier String? - refreshToken String - createdAt DateTime - updatedAt DateTime - provider String? - secretBlock String? - pubA String? - pubB String? - secB String? - attributes UserAttribute[] - UserPool UserPool @relation(fields: [userPoolId], references: [id]) - userPoolId String + id String @id + kind String? + name String + email String + enabled Boolean + status String + password String? + confirmationCode String? + salt String? + verifier String? + refreshToken String + createdAt DateTime + updatedAt DateTime + provider String? + authorizationCode String? + codeChallenge String? + secretBlock String? + pubA String? + pubB String? + secB String? + attributes UserAttribute[] + UserPool UserPool @relation(fields: [userPoolId], references: [id]) + userPoolId String } model UserAttribute { diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index abf516a..581f9d4 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,15 +1,38 @@ import type { Prisma } from '@prisma/client'; import assert from 'assert'; import { USER_KINDS } from 'common/constants'; +import { createHash } from 'crypto'; import { prismaClient, transaction } from 'service/prismaClient'; +import { ulid } from 'ulid'; const migrateUser = async (tx: Prisma.TransactionClient): Promise => { const users = await tx.user.findMany({ where: { kind: null } }); if (users.length > 0) { - await tx.user.updateMany({ - data: users.map((user) => ({ ...user, kind: USER_KINDS.cognito })), - }); + await Promise.all( + users.map(({ id, ...user }) => + tx.user.update({ where: { id }, data: { ...user, kind: USER_KINDS.cognito } }), + ), + ); + } + + const socials = await tx.user.findMany({ + where: { kind: USER_KINDS.social, authorizationCode: null }, + }); + + if (socials.length > 0) { + await Promise.all( + socials.map(({ id, ...s }) => + tx.user.update({ + where: { id }, + data: { + ...s, + authorizationCode: ulid(), + codeChallenge: createHash('sha256').update(ulid()).digest('base64url'), + }, + }), + ), + ); } const test = async (): Promise => { @@ -17,6 +40,10 @@ const migrateUser = async (tx: Prisma.TransactionClient): Promise => { users.forEach((user) => { assert(user.kind !== null); + assert( + user.kind === USER_KINDS.cognito || + (user.authorizationCode !== null && user.codeChallenge !== null), + ); }); }; diff --git a/server/tests/api/social.test.ts b/server/tests/api/social.test.ts index eb8b224..0545747 100644 --- a/server/tests/api/social.test.ts +++ b/server/tests/api/social.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { DEFAULT_USER_POOL_CLIENT_ID } from 'service/envValues'; import { ulid } from 'ulid'; import { expect, test } from 'vitest'; @@ -13,6 +14,7 @@ test(POST(noCookieClient.public.socialUsers), async () => { provider: 'Google', name: name1, email: email1, + codeChallenge: createHash('sha256').update(ulid()).digest('base64url'), userPoolClientId: DEFAULT_USER_POOL_CLIENT_ID, }, }); @@ -26,6 +28,7 @@ test(POST(noCookieClient.public.socialUsers), async () => { provider: 'Amazon', name: name2, email: email2, + codeChallenge: createHash('sha256').update(ulid()).digest('base64url'), photoUrl, userPoolClientId: DEFAULT_USER_POOL_CLIENT_ID, },