@stjoseph/prumo 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +51 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { type Grant, Session } from '@/domain/auth/entities/session.entity'
|
|
3
|
+
import {
|
|
4
|
+
ACCESS_TOKEN_LIFETIME_MS,
|
|
5
|
+
ACCESS_TOKENS,
|
|
6
|
+
type AccessTokens,
|
|
7
|
+
type IssuedToken,
|
|
8
|
+
} from '@/domain/auth/ports/access-tokens.port'
|
|
9
|
+
import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
10
|
+
import {
|
|
11
|
+
SESSION_REPOSITORY,
|
|
12
|
+
type SessionRepository,
|
|
13
|
+
} from '@/domain/auth/repositories/session.repository'
|
|
14
|
+
|
|
15
|
+
export type SessionTokens = {
|
|
16
|
+
sessionId: string
|
|
17
|
+
accessToken: IssuedToken
|
|
18
|
+
refreshToken: string
|
|
19
|
+
refreshExpiresAt: Date
|
|
20
|
+
// What an OAuth client's tokens may do; null for the application's own sessions, which may do everything.
|
|
21
|
+
scope: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@injectable()
|
|
25
|
+
export class StartSessionUseCase {
|
|
26
|
+
constructor(
|
|
27
|
+
@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
|
|
28
|
+
@inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
|
|
29
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
async execute(userId: string, grant?: Grant): Promise<SessionTokens> {
|
|
33
|
+
const now = new Date()
|
|
34
|
+
const secret = this.opaque.create()
|
|
35
|
+
const session = new Session(userId, this.opaque.digest(secret), now, grant)
|
|
36
|
+
|
|
37
|
+
await this.sessions.save(session)
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
sessionId: session.id,
|
|
41
|
+
accessToken: await this.tokens.issue(
|
|
42
|
+
{
|
|
43
|
+
userId,
|
|
44
|
+
sessionId: session.id,
|
|
45
|
+
...(grant && { clientId: grant.clientId, scope: grant.scope }),
|
|
46
|
+
},
|
|
47
|
+
new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
|
|
48
|
+
grant?.resource,
|
|
49
|
+
),
|
|
50
|
+
refreshToken: session.refreshToken(secret),
|
|
51
|
+
refreshExpiresAt: session.expiresAt,
|
|
52
|
+
scope: session.scope,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { type SocialClient, SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
3
|
+
import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.error'
|
|
4
|
+
import {
|
|
5
|
+
IDENTITY_PROVIDERS,
|
|
6
|
+
type IdentityProviders,
|
|
7
|
+
type ProviderName,
|
|
8
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
9
|
+
import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
10
|
+
import {
|
|
11
|
+
SOCIAL_SIGN_IN_REPOSITORY,
|
|
12
|
+
type SocialSignInRepository,
|
|
13
|
+
} from '@/domain/auth/repositories/social-sign-in.repository'
|
|
14
|
+
|
|
15
|
+
export type SocialStart = { provider: ProviderName; client: SocialClient; returnTo: string }
|
|
16
|
+
|
|
17
|
+
@injectable()
|
|
18
|
+
export class StartSocialSignInUseCase {
|
|
19
|
+
constructor(
|
|
20
|
+
@inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
|
|
21
|
+
@inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
|
|
22
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
// Returns where to send the browser, and the token the browser keeps to prove, on the way back, that it started.
|
|
26
|
+
async execute({
|
|
27
|
+
provider,
|
|
28
|
+
client,
|
|
29
|
+
returnTo,
|
|
30
|
+
}: SocialStart): Promise<{ url: string; browserToken: string }> {
|
|
31
|
+
if (!this.providers.isConfigured(provider)) {
|
|
32
|
+
throw new ProviderNotConfiguredError(provider)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const secret = this.opaque.create()
|
|
36
|
+
const signIn = new SocialSignIn(
|
|
37
|
+
provider,
|
|
38
|
+
client,
|
|
39
|
+
returnTo,
|
|
40
|
+
this.opaque.digest(secret),
|
|
41
|
+
new Date(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Saved first: its id is the state the provider sends back.
|
|
45
|
+
await this.signIns.save(signIn)
|
|
46
|
+
|
|
47
|
+
const redirect = await this.providers.redirect(provider, signIn.id)
|
|
48
|
+
|
|
49
|
+
signIn.prepare(redirect.codeVerifier, redirect.nonce)
|
|
50
|
+
await this.signIns.save(signIn)
|
|
51
|
+
|
|
52
|
+
return { url: redirect.url, browserToken: signIn.token(secret) }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { VerifyEmailDto } from '@/domain/auth/dto/verify-email.dto'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
|
|
5
|
+
import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
6
|
+
import {
|
|
7
|
+
EMAIL_CODE_REPOSITORY,
|
|
8
|
+
type EmailCodeRepository,
|
|
9
|
+
} from '@/domain/auth/repositories/email-code.repository'
|
|
10
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
11
|
+
import {
|
|
12
|
+
type SessionTokens,
|
|
13
|
+
StartSessionUseCase,
|
|
14
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
15
|
+
import {
|
|
16
|
+
TRANSACTION_MANAGER,
|
|
17
|
+
type TransactionManager,
|
|
18
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
19
|
+
|
|
20
|
+
@injectable()
|
|
21
|
+
export class VerifyEmailUseCase {
|
|
22
|
+
constructor(
|
|
23
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
24
|
+
@inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
|
|
25
|
+
@inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
|
|
26
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
27
|
+
private readonly startSession: StartSessionUseCase,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
// A confirmed address signs the user in: they proved the inbox and already chose a password.
|
|
31
|
+
async execute({ email, code }: VerifyEmailDto): Promise<SessionTokens> {
|
|
32
|
+
// The spent attempt is committed before the error is thrown, or a wrong guess would cost nothing.
|
|
33
|
+
const verified = await this.transactions.run(async () => {
|
|
34
|
+
const now = new Date()
|
|
35
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
36
|
+
const stored = user === null ? null : await this.codes.lockFor(user.id, 'verify-email')
|
|
37
|
+
|
|
38
|
+
if (user === null || stored === null) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const outcome = stored.check(this.oneTimeCodes.digest(code), now)
|
|
43
|
+
|
|
44
|
+
await this.codes.save(stored)
|
|
45
|
+
|
|
46
|
+
if (outcome !== 'valid') {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
user.verifyEmail(now)
|
|
51
|
+
await this.users.save(user)
|
|
52
|
+
|
|
53
|
+
return user
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (verified === null) {
|
|
57
|
+
throw new InvalidCodeError()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.startSession.execute(verified.id)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const authorizationDecisionSchema = z.strictObject({ accept: z.boolean() })
|
|
4
|
+
|
|
5
|
+
export type AuthorizationDecisionDto = z.infer<typeof authorizationDecisionSchema>
|
|
6
|
+
|
|
7
|
+
export const authorizationRedirectSchema = z.object({ redirectTo: z.string() })
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// What the consent page shows. The hosts come first: a name is whatever the client chose to write.
|
|
4
|
+
export const authorizationViewSchema = z.object({
|
|
5
|
+
clientId: z.string(),
|
|
6
|
+
clientName: z.string(),
|
|
7
|
+
clientHost: z.string(),
|
|
8
|
+
redirectHost: z.string(),
|
|
9
|
+
// A localhost redirect cannot prove which program is listening there, so the page warns about it.
|
|
10
|
+
redirectsToThisDevice: z.boolean(),
|
|
11
|
+
scopes: z.array(z.object({ scope: z.string(), description: z.string() })),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Long enough to sign in and read the consent page; the code that follows is spent within a minute.
|
|
2
|
+
export const AUTHORIZATION_REQUEST_LIFETIME_MS = 10 * 60 * 1000
|
|
3
|
+
export const AUTHORIZATION_CODE_LIFETIME_MS = 60 * 1000
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
6
|
+
|
|
7
|
+
export type AuthorizationStatus = 'pending' | 'approved' | 'denied' | 'exchanged'
|
|
8
|
+
|
|
9
|
+
export type AuthorizationRequest = {
|
|
10
|
+
clientId: string
|
|
11
|
+
clientName: string
|
|
12
|
+
redirectUri: string
|
|
13
|
+
codeChallenge: string
|
|
14
|
+
clientState: string | null
|
|
15
|
+
resource: string
|
|
16
|
+
// The scopes the client asked for and the user is shown, space-separated.
|
|
17
|
+
scope: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
|
|
21
|
+
export class Authorization {
|
|
22
|
+
id: string
|
|
23
|
+
clientId: string
|
|
24
|
+
clientName: string
|
|
25
|
+
redirectUri: string
|
|
26
|
+
codeChallenge: string
|
|
27
|
+
clientState: string | null
|
|
28
|
+
resource: string
|
|
29
|
+
scope: string
|
|
30
|
+
status: AuthorizationStatus = 'pending'
|
|
31
|
+
userId: string | null = null
|
|
32
|
+
codeHash: string | null = null
|
|
33
|
+
sessionId: string | null = null
|
|
34
|
+
expiresAt: Date
|
|
35
|
+
createdAt: Date
|
|
36
|
+
updatedAt: Date
|
|
37
|
+
|
|
38
|
+
constructor(request: AuthorizationRequest, now: Date) {
|
|
39
|
+
this.clientId = request.clientId
|
|
40
|
+
this.clientName = request.clientName
|
|
41
|
+
this.redirectUri = request.redirectUri
|
|
42
|
+
this.codeChallenge = request.codeChallenge
|
|
43
|
+
this.clientState = request.clientState
|
|
44
|
+
this.resource = request.resource
|
|
45
|
+
this.scope = request.scope
|
|
46
|
+
this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The code names its authorization, as a refresh token names its session.
|
|
50
|
+
static parseCode(code: string): { authorizationId: string; secret: string } | null {
|
|
51
|
+
const dot = code.indexOf('.')
|
|
52
|
+
const authorizationId = code.slice(0, dot)
|
|
53
|
+
const secret = code.slice(dot + 1)
|
|
54
|
+
|
|
55
|
+
return dot === -1 || !UUID.test(authorizationId) || secret === ''
|
|
56
|
+
? null
|
|
57
|
+
: { authorizationId, secret }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
code(secret: string): string {
|
|
61
|
+
return `${this.id}.${secret}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isPending(now: Date): boolean {
|
|
65
|
+
return this.status === 'pending' && this.expiresAt > now
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
approve(userId: string, codeHash: string, now: Date): void {
|
|
69
|
+
this.status = 'approved'
|
|
70
|
+
this.userId = userId
|
|
71
|
+
this.codeHash = codeHash
|
|
72
|
+
this.expiresAt = new Date(now.getTime() + AUTHORIZATION_CODE_LIFETIME_MS)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
deny(): void {
|
|
76
|
+
this.status = 'denied'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
isRedeemable(now: Date): boolean {
|
|
80
|
+
return this.status === 'approved' && this.expiresAt > now
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
exchange(sessionId: string): void {
|
|
84
|
+
this.status = 'exchanged'
|
|
85
|
+
this.sessionId = sessionId
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// A request from a client already known to be who it says, with a redirect URI it registered: the error goes back
|
|
4
|
+
// to that URI, in OAuth's own terms.
|
|
5
|
+
export class AuthorizationRequestError extends DomainError {
|
|
6
|
+
readonly kind = 'invalid'
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
readonly code:
|
|
10
|
+
| 'invalid_request'
|
|
11
|
+
| 'unsupported_response_type'
|
|
12
|
+
| 'invalid_target'
|
|
13
|
+
| 'invalid_scope',
|
|
14
|
+
message: string,
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface AuthorizationCodes {
|
|
2
|
+
create(): string
|
|
3
|
+
digest(code: string): string
|
|
4
|
+
// PKCE's S256: whether the verifier the client kept hashes to the challenge it sent first.
|
|
5
|
+
matchesChallenge(verifier: string, challenge: string): boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const AUTHORIZATION_CODES = Symbol('AuthorizationCodes')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ClientMetadata = { clientId: string; clientName: string; redirectUris: string[] }
|
|
2
|
+
|
|
3
|
+
// A client identifies itself by the HTTPS URL of a document describing it. Reading one that is missing, malformed,
|
|
4
|
+
// or not a public client's throws InvalidClientError.
|
|
5
|
+
export interface ClientMetadataSource {
|
|
6
|
+
read(clientId: string): Promise<ClientMetadata>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const CLIENT_METADATA = Symbol('ClientMetadataSource')
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
|
|
2
|
+
|
|
3
|
+
export interface AuthorizationRepository {
|
|
4
|
+
findById(id: string): Promise<Authorization | null>
|
|
5
|
+
// Reads the row and holds it until the surrounding transaction ends, so a code is spent once.
|
|
6
|
+
lockById(id: string): Promise<Authorization | null>
|
|
7
|
+
save(authorization: Authorization): Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const AUTHORIZATION_REPOSITORY = Symbol('AuthorizationRepository')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
|
|
3
|
+
import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
|
|
4
|
+
import {
|
|
5
|
+
AUTHORIZATION_CODES,
|
|
6
|
+
type AuthorizationCodes,
|
|
7
|
+
} from '@/domain/oauth/ports/authorization-codes.port'
|
|
8
|
+
import {
|
|
9
|
+
AUTHORIZATION_REPOSITORY,
|
|
10
|
+
type AuthorizationRepository,
|
|
11
|
+
} from '@/domain/oauth/repositories/authorization.repository'
|
|
12
|
+
import {
|
|
13
|
+
TRANSACTION_MANAGER,
|
|
14
|
+
type TransactionManager,
|
|
15
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
16
|
+
|
|
17
|
+
export type Decision = { authorization: Authorization; code: string | null }
|
|
18
|
+
|
|
19
|
+
@injectable()
|
|
20
|
+
export class DecideAuthorizationUseCase {
|
|
21
|
+
constructor(
|
|
22
|
+
@inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
|
|
23
|
+
@inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
|
|
24
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
// The user who answers is the user the client will act for.
|
|
28
|
+
execute(id: string, userId: string, accept: boolean): Promise<Decision> {
|
|
29
|
+
return this.transactions.run(async () => {
|
|
30
|
+
const now = new Date()
|
|
31
|
+
const authorization = await this.authorizations.lockById(id)
|
|
32
|
+
|
|
33
|
+
if (authorization === null || !authorization.isPending(now)) {
|
|
34
|
+
throw new AuthorizationNotFoundError()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let code: string | null = null
|
|
38
|
+
|
|
39
|
+
if (accept) {
|
|
40
|
+
const secret = this.codes.create()
|
|
41
|
+
|
|
42
|
+
authorization.approve(userId, this.codes.digest(secret), now)
|
|
43
|
+
code = authorization.code(secret)
|
|
44
|
+
} else {
|
|
45
|
+
authorization.deny()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await this.authorizations.save(authorization)
|
|
49
|
+
|
|
50
|
+
return { authorization, code }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
3
|
+
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
4
|
+
import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
|
|
5
|
+
import { Authorization } from '@/domain/oauth/entities/authorization.entity'
|
|
6
|
+
import { InvalidGrantError } from '@/domain/oauth/errors/invalid-grant.error'
|
|
7
|
+
import {
|
|
8
|
+
AUTHORIZATION_CODES,
|
|
9
|
+
type AuthorizationCodes,
|
|
10
|
+
} from '@/domain/oauth/ports/authorization-codes.port'
|
|
11
|
+
import {
|
|
12
|
+
AUTHORIZATION_REPOSITORY,
|
|
13
|
+
type AuthorizationRepository,
|
|
14
|
+
} from '@/domain/oauth/repositories/authorization.repository'
|
|
15
|
+
import {
|
|
16
|
+
TRANSACTION_MANAGER,
|
|
17
|
+
type TransactionManager,
|
|
18
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
19
|
+
|
|
20
|
+
export type CodeExchange = {
|
|
21
|
+
code: string
|
|
22
|
+
clientId: string
|
|
23
|
+
redirectUri: string
|
|
24
|
+
codeVerifier: string
|
|
25
|
+
resource: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@injectable()
|
|
29
|
+
export class ExchangeCodeUseCase {
|
|
30
|
+
constructor(
|
|
31
|
+
@inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
|
|
32
|
+
@inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
|
|
33
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
34
|
+
private readonly startSession: StartSessionUseCase,
|
|
35
|
+
private readonly signOut: SignOutUseCase,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
async execute(request: CodeExchange): Promise<SessionTokens> {
|
|
39
|
+
const parsed = Authorization.parseCode(request.code)
|
|
40
|
+
|
|
41
|
+
if (parsed === null) {
|
|
42
|
+
throw new InvalidGrantError('Unknown authorization code')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const outcome = await this.transactions.run(async () => {
|
|
46
|
+
const now = new Date()
|
|
47
|
+
const authorization = await this.authorizations.lockById(parsed.authorizationId)
|
|
48
|
+
|
|
49
|
+
if (authorization === null || authorization.codeHash !== this.codes.digest(parsed.secret)) {
|
|
50
|
+
return { error: 'Unknown authorization code' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A code presented twice was copied: the tokens the first use obtained are no longer trusted either.
|
|
54
|
+
if (authorization.status === 'exchanged' && authorization.userId !== null) {
|
|
55
|
+
if (authorization.sessionId !== null) {
|
|
56
|
+
await this.signOut.execute(authorization.userId, authorization.sessionId)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { error: 'Authorization code already used' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!authorization.isRedeemable(now) || authorization.userId === null) {
|
|
63
|
+
return { error: 'Authorization code expired' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
authorization.clientId !== request.clientId ||
|
|
68
|
+
authorization.redirectUri !== request.redirectUri ||
|
|
69
|
+
authorization.resource !== request.resource
|
|
70
|
+
) {
|
|
71
|
+
return { error: 'The code was issued for another client, redirect URI or resource' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!this.codes.matchesChallenge(request.codeVerifier, authorization.codeChallenge)) {
|
|
75
|
+
return { error: 'code_verifier does not match the code_challenge' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tokens = await this.startSession.execute(authorization.userId, {
|
|
79
|
+
clientId: authorization.clientId,
|
|
80
|
+
resource: authorization.resource,
|
|
81
|
+
scope: authorization.scope,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
authorization.exchange(tokens.sessionId)
|
|
85
|
+
await this.authorizations.save(authorization)
|
|
86
|
+
|
|
87
|
+
return { tokens }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Thrown after the transaction, so revoking a copied code's session is committed and not rolled back.
|
|
91
|
+
if ('error' in outcome) {
|
|
92
|
+
throw new InvalidGrantError(outcome.error)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return outcome.tokens
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
|
|
3
|
+
import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
|
|
4
|
+
import {
|
|
5
|
+
AUTHORIZATION_REPOSITORY,
|
|
6
|
+
type AuthorizationRepository,
|
|
7
|
+
} from '@/domain/oauth/repositories/authorization.repository'
|
|
8
|
+
|
|
9
|
+
@injectable()
|
|
10
|
+
export class FindAuthorizationUseCase {
|
|
11
|
+
constructor(
|
|
12
|
+
@inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async execute(id: string): Promise<Authorization> {
|
|
16
|
+
const authorization = await this.authorizations.findById(id)
|
|
17
|
+
|
|
18
|
+
if (authorization === null || !authorization.isPending(new Date())) {
|
|
19
|
+
throw new AuthorizationNotFoundError()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return authorization
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { Authorization } from '@/domain/oauth/entities/authorization.entity'
|
|
3
|
+
import { AuthorizationRequestError } from '@/domain/oauth/errors/authorization-request.error'
|
|
4
|
+
import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
|
|
5
|
+
import {
|
|
6
|
+
CLIENT_METADATA,
|
|
7
|
+
type ClientMetadataSource,
|
|
8
|
+
} from '@/domain/oauth/ports/client-metadata.port'
|
|
9
|
+
import {
|
|
10
|
+
AUTHORIZATION_REPOSITORY,
|
|
11
|
+
type AuthorizationRepository,
|
|
12
|
+
} from '@/domain/oauth/repositories/authorization.repository'
|
|
13
|
+
|
|
14
|
+
// RFC 7636: 43 to 128 characters from the unreserved set.
|
|
15
|
+
const CODE_CHALLENGE = /^[A-Za-z0-9\-._~]{43,128}$/
|
|
16
|
+
|
|
17
|
+
export type AuthorizeRequest = {
|
|
18
|
+
responseType: string | undefined
|
|
19
|
+
clientId: string
|
|
20
|
+
redirectUri: string
|
|
21
|
+
codeChallenge: string | undefined
|
|
22
|
+
codeChallengeMethod: string | undefined
|
|
23
|
+
clientState: string | null
|
|
24
|
+
resource: string | undefined
|
|
25
|
+
scope: string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@injectable()
|
|
29
|
+
export class StartAuthorizationUseCase {
|
|
30
|
+
constructor(
|
|
31
|
+
@inject(CLIENT_METADATA) private readonly clients: ClientMetadataSource,
|
|
32
|
+
@inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
// The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
|
|
36
|
+
async execute(
|
|
37
|
+
request: AuthorizeRequest,
|
|
38
|
+
expectedResource: string,
|
|
39
|
+
supportedScopes: readonly string[],
|
|
40
|
+
): Promise<Authorization> {
|
|
41
|
+
const client = await this.clients.read(request.clientId)
|
|
42
|
+
|
|
43
|
+
// Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
|
|
44
|
+
if (!client.redirectUris.includes(request.redirectUri)) {
|
|
45
|
+
throw new InvalidClientError('redirect_uri is not one the client registered')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (request.responseType !== 'code') {
|
|
49
|
+
throw new AuthorizationRequestError('unsupported_response_type', 'Only response_type=code')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
request.codeChallengeMethod !== 'S256' ||
|
|
54
|
+
!CODE_CHALLENGE.test(request.codeChallenge ?? '')
|
|
55
|
+
) {
|
|
56
|
+
throw new AuthorizationRequestError('invalid_request', 'PKCE with S256 is required')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// RFC 8707: the token is for one resource, and this server has one.
|
|
60
|
+
if (request.resource !== expectedResource) {
|
|
61
|
+
throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A client that asks for nothing gets everything this server offers, and the consent page says so.
|
|
65
|
+
const requested = (request.scope ?? '').split(' ').filter(Boolean)
|
|
66
|
+
const unknown = requested.filter((scope) => !supportedScopes.includes(scope))
|
|
67
|
+
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
throw new AuthorizationRequestError('invalid_scope', `Unknown scope: ${unknown.join(' ')}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const authorization = new Authorization(
|
|
73
|
+
{
|
|
74
|
+
clientId: request.clientId,
|
|
75
|
+
clientName: client.clientName,
|
|
76
|
+
redirectUri: request.redirectUri,
|
|
77
|
+
codeChallenge: request.codeChallenge as string,
|
|
78
|
+
clientState: request.clientState,
|
|
79
|
+
resource: expectedResource,
|
|
80
|
+
scope: (requested.length > 0 ? requested : supportedScopes).join(' '),
|
|
81
|
+
},
|
|
82
|
+
new Date(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await this.authorizations.save(authorization)
|
|
86
|
+
|
|
87
|
+
return authorization
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -2,4 +2,6 @@ export type DomainErrorKind = 'not_found' | 'conflict' | 'invalid' | 'forbidden'
|
|
|
2
2
|
|
|
3
3
|
export abstract class DomainError extends Error {
|
|
4
4
|
abstract readonly kind: DomainErrorKind
|
|
5
|
+
// A stable name a client can act on, when the status alone does not say what to do next.
|
|
6
|
+
readonly code?: string
|
|
5
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { hash, verify } from '@node-rs/argon2'
|
|
2
|
+
import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
3
|
+
|
|
4
|
+
// The library's defaults are Argon2id with 19 MiB, two passes and one lane: OWASP's minimum, so none is restated.
|
|
5
|
+
export class Argon2PasswordHasher implements PasswordHasher {
|
|
6
|
+
// A real hash of nothing in particular, so an unknown email costs one verification like any other.
|
|
7
|
+
private readonly decoy = hash('decoy password that nobody holds')
|
|
8
|
+
|
|
9
|
+
hash(password: string): Promise<string> {
|
|
10
|
+
return hash(password)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async verify(stored: string | undefined, password: string): Promise<boolean> {
|
|
14
|
+
if (stored === undefined) {
|
|
15
|
+
await verify(await this.decoy, password)
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return verify(stored, password)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
3
|
+
|
|
4
|
+
// 256 random bits need no slow hash: nothing can be guessed, so a plain SHA-256 is enough to keep a stolen
|
|
5
|
+
// database from handing out working tokens.
|
|
6
|
+
export class CryptoOpaqueTokens implements OpaqueTokens {
|
|
7
|
+
create(): string {
|
|
8
|
+
return randomBytes(32).toString('base64url')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(token: string): string {
|
|
12
|
+
return createHash('sha256').update(token).digest('base64url')
|
|
13
|
+
}
|
|
14
|
+
}
|