@stjoseph/prumo 0.1.1 → 0.3.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 +50 -48
- 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 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- 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/README.md +19 -14
- package/dist/clean.js +13 -3
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +4 -12
- package/dist/doctor.js +31 -17
- package/dist/generate.js +56 -9
- package/dist/mcp.js +15 -8
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +5 -6
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -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/refresh.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/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -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/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/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/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -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 +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -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 +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -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 +14 -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 +96 -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 +75 -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 +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -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 +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -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/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -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/mcp/mcp.server.ts +10 -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/test/domain/auth/entities/session.entity.spec.ts +50 -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 +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -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/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- 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/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -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-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +1 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -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/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +1 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- 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/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/README.md +1 -1
- package/templates/workspace/package.json +1 -1
- package/templates/workspace/scripts/ports.mjs +1 -1
- 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,92 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { Session } from '@/domain/auth/entities/session.entity'
|
|
3
|
+
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
4
|
+
import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
|
|
5
|
+
import {
|
|
6
|
+
ACCESS_TOKEN_LIFETIME_MS,
|
|
7
|
+
ACCESS_TOKENS,
|
|
8
|
+
type AccessTokens,
|
|
9
|
+
} from '@/domain/auth/ports/access-tokens.port'
|
|
10
|
+
import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
11
|
+
import {
|
|
12
|
+
SESSION_REPOSITORY,
|
|
13
|
+
type SessionRepository,
|
|
14
|
+
} from '@/domain/auth/repositories/session.repository'
|
|
15
|
+
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
16
|
+
import {
|
|
17
|
+
TRANSACTION_MANAGER,
|
|
18
|
+
type TransactionManager,
|
|
19
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
20
|
+
|
|
21
|
+
@injectable()
|
|
22
|
+
export class RefreshSessionUseCase {
|
|
23
|
+
constructor(
|
|
24
|
+
@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
|
|
25
|
+
@inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
|
|
26
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
27
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
// A first-party refresh passes no client; an OAuth client passes its own id, and neither can use the other's token.
|
|
31
|
+
async execute(refreshToken: string, clientId: string | null = null): Promise<SessionTokens> {
|
|
32
|
+
const parsed = Session.parseRefreshToken(refreshToken)
|
|
33
|
+
|
|
34
|
+
if (parsed === null) {
|
|
35
|
+
throw new InvalidSessionError()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { sessionId, secret } = parsed
|
|
39
|
+
const now = new Date()
|
|
40
|
+
const rotated = await this.transactions.run(async () => {
|
|
41
|
+
const session = await this.sessions.lockById(sessionId)
|
|
42
|
+
|
|
43
|
+
if (session === null || session.clientId !== clientId) {
|
|
44
|
+
return 'missing' as const
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const outcome = session.check(this.opaque.digest(secret), now)
|
|
48
|
+
|
|
49
|
+
if (outcome === 'reused') {
|
|
50
|
+
// An old token came back: whoever holds it is not the client that rotated it. End the session for both.
|
|
51
|
+
session.revoke(now)
|
|
52
|
+
await this.sessions.save(session)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (outcome !== 'valid') {
|
|
56
|
+
return outcome
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const next = this.opaque.create()
|
|
60
|
+
|
|
61
|
+
session.rotate(this.opaque.digest(next), now)
|
|
62
|
+
await this.sessions.save(session)
|
|
63
|
+
|
|
64
|
+
return { session, next }
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (rotated === 'superseded') {
|
|
68
|
+
throw new SupersededTokenError()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof rotated === 'string') {
|
|
72
|
+
throw new InvalidSessionError()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { session, next } = rotated
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
sessionId: session.id,
|
|
79
|
+
accessToken: await this.tokens.issue(
|
|
80
|
+
{
|
|
81
|
+
userId: session.userId,
|
|
82
|
+
sessionId: session.id,
|
|
83
|
+
...(session.clientId !== null && { clientId: session.clientId }),
|
|
84
|
+
},
|
|
85
|
+
new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
|
|
86
|
+
session.resource ?? undefined,
|
|
87
|
+
),
|
|
88
|
+
refreshToken: session.refreshToken(next),
|
|
89
|
+
refreshExpiresAt: session.expiresAt,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { SignInDto } from '@/domain/auth/dto/sign-in.dto'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
|
|
5
|
+
import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
6
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
7
|
+
import {
|
|
8
|
+
type SessionTokens,
|
|
9
|
+
StartSessionUseCase,
|
|
10
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
11
|
+
|
|
12
|
+
@injectable()
|
|
13
|
+
export class SignInUseCase {
|
|
14
|
+
constructor(
|
|
15
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
16
|
+
@inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
|
|
17
|
+
private readonly startSession: StartSessionUseCase,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async execute({ email, password }: SignInDto): Promise<SessionTokens> {
|
|
21
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
22
|
+
const valid = await this.hasher.verify(user?.passwordHash, password)
|
|
23
|
+
|
|
24
|
+
if (user === null || !valid) {
|
|
25
|
+
throw new InvalidCredentialsError()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return this.startSession.execute(user.id)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import {
|
|
3
|
+
SESSION_REPOSITORY,
|
|
4
|
+
type SessionRepository,
|
|
5
|
+
} from '@/domain/auth/repositories/session.repository'
|
|
6
|
+
|
|
7
|
+
@injectable()
|
|
8
|
+
export class SignOutUseCase {
|
|
9
|
+
constructor(@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository) {}
|
|
10
|
+
|
|
11
|
+
// Signing out twice, or out of a session already gone, is not an error: the outcome is the same.
|
|
12
|
+
async execute(userId: string, sessionId: string): Promise<void> {
|
|
13
|
+
const session = await this.sessions.findById(sessionId)
|
|
14
|
+
|
|
15
|
+
if (session === null || session.userId !== userId) {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
session.revoke(new Date())
|
|
20
|
+
await this.sessions.save(session)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
|
|
5
|
+
import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
6
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
7
|
+
import {
|
|
8
|
+
type SessionTokens,
|
|
9
|
+
StartSessionUseCase,
|
|
10
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
11
|
+
import {
|
|
12
|
+
TRANSACTION_MANAGER,
|
|
13
|
+
type TransactionManager,
|
|
14
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
15
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
16
|
+
|
|
17
|
+
@injectable()
|
|
18
|
+
export class SignUpUseCase {
|
|
19
|
+
constructor(
|
|
20
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
21
|
+
@inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
|
|
22
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
23
|
+
private readonly createProfile: CreateProfileUseCase,
|
|
24
|
+
private readonly startSession: StartSessionUseCase,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async execute({ name, email, password }: SignUpDto): Promise<SessionTokens> {
|
|
28
|
+
const passwordHash = await this.hasher.hash(password)
|
|
29
|
+
|
|
30
|
+
// The account and its profile exist together or not at all.
|
|
31
|
+
const user = await this.transactions.run(async () => {
|
|
32
|
+
if ((await this.users.findByEmail(User.normalizeEmail(email))) !== null) {
|
|
33
|
+
throw new EmailTakenError()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const user = new User(email, passwordHash)
|
|
37
|
+
|
|
38
|
+
await this.users.save(user)
|
|
39
|
+
await this.createProfile.execute(user.id, name)
|
|
40
|
+
|
|
41
|
+
return user
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return this.startSession.execute(user.id)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
@injectable()
|
|
23
|
+
export class StartSessionUseCase {
|
|
24
|
+
constructor(
|
|
25
|
+
@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
|
|
26
|
+
@inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
|
|
27
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
async execute(userId: string, grant?: Grant): Promise<SessionTokens> {
|
|
31
|
+
const now = new Date()
|
|
32
|
+
const secret = this.opaque.create()
|
|
33
|
+
const session = new Session(userId, this.opaque.digest(secret), now, grant)
|
|
34
|
+
|
|
35
|
+
await this.sessions.save(session)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
sessionId: session.id,
|
|
39
|
+
accessToken: await this.tokens.issue(
|
|
40
|
+
{ userId, sessionId: session.id, ...(grant && { clientId: grant.clientId }) },
|
|
41
|
+
new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
|
|
42
|
+
grant?.resource,
|
|
43
|
+
),
|
|
44
|
+
refreshToken: session.refreshToken(secret),
|
|
45
|
+
refreshExpiresAt: session.expiresAt,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -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,13 @@
|
|
|
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
|
+
})
|
|
12
|
+
|
|
13
|
+
export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
}
|
|
17
|
+
|
|
18
|
+
// One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
|
|
19
|
+
export class Authorization {
|
|
20
|
+
id: string
|
|
21
|
+
clientId: string
|
|
22
|
+
clientName: string
|
|
23
|
+
redirectUri: string
|
|
24
|
+
codeChallenge: string
|
|
25
|
+
clientState: string | null
|
|
26
|
+
resource: string
|
|
27
|
+
status: AuthorizationStatus = 'pending'
|
|
28
|
+
userId: string | null = null
|
|
29
|
+
codeHash: string | null = null
|
|
30
|
+
sessionId: string | null = null
|
|
31
|
+
expiresAt: Date
|
|
32
|
+
createdAt: Date
|
|
33
|
+
updatedAt: Date
|
|
34
|
+
|
|
35
|
+
constructor(request: AuthorizationRequest, now: Date) {
|
|
36
|
+
this.clientId = request.clientId
|
|
37
|
+
this.clientName = request.clientName
|
|
38
|
+
this.redirectUri = request.redirectUri
|
|
39
|
+
this.codeChallenge = request.codeChallenge
|
|
40
|
+
this.clientState = request.clientState
|
|
41
|
+
this.resource = request.resource
|
|
42
|
+
this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The code names its authorization, as a refresh token names its session.
|
|
46
|
+
static parseCode(code: string): { authorizationId: string; secret: string } | null {
|
|
47
|
+
const dot = code.indexOf('.')
|
|
48
|
+
const authorizationId = code.slice(0, dot)
|
|
49
|
+
const secret = code.slice(dot + 1)
|
|
50
|
+
|
|
51
|
+
return dot === -1 || !UUID.test(authorizationId) || secret === ''
|
|
52
|
+
? null
|
|
53
|
+
: { authorizationId, secret }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
code(secret: string): string {
|
|
57
|
+
return `${this.id}.${secret}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isPending(now: Date): boolean {
|
|
61
|
+
return this.status === 'pending' && this.expiresAt > now
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
approve(userId: string, codeHash: string, now: Date): void {
|
|
65
|
+
this.status = 'approved'
|
|
66
|
+
this.userId = userId
|
|
67
|
+
this.codeHash = codeHash
|
|
68
|
+
this.expiresAt = new Date(now.getTime() + AUTHORIZATION_CODE_LIFETIME_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
deny(): void {
|
|
72
|
+
this.status = 'denied'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isRedeemable(now: Date): boolean {
|
|
76
|
+
return this.status === 'approved' && this.expiresAt > now
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
exchange(sessionId: string): void {
|
|
80
|
+
this.status = 'exchanged'
|
|
81
|
+
this.sessionId = sessionId
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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: 'invalid_request' | 'unsupported_response_type' | 'invalid_target',
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -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,96 @@
|
|
|
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
|
+
})
|
|
82
|
+
|
|
83
|
+
authorization.exchange(tokens.sessionId)
|
|
84
|
+
await this.authorizations.save(authorization)
|
|
85
|
+
|
|
86
|
+
return { tokens }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Thrown after the transaction, so revoking a copied code's session is committed and not rolled back.
|
|
90
|
+
if ('error' in outcome) {
|
|
91
|
+
throw new InvalidGrantError(outcome.error)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return outcome.tokens
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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,75 @@
|
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
@injectable()
|
|
28
|
+
export class StartAuthorizationUseCase {
|
|
29
|
+
constructor(
|
|
30
|
+
@inject(CLIENT_METADATA) private readonly clients: ClientMetadataSource,
|
|
31
|
+
@inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
// The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
|
|
35
|
+
async execute(request: AuthorizeRequest, expectedResource: string): Promise<Authorization> {
|
|
36
|
+
const client = await this.clients.read(request.clientId)
|
|
37
|
+
|
|
38
|
+
// Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
|
|
39
|
+
if (!client.redirectUris.includes(request.redirectUri)) {
|
|
40
|
+
throw new InvalidClientError('redirect_uri is not one the client registered')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (request.responseType !== 'code') {
|
|
44
|
+
throw new AuthorizationRequestError('unsupported_response_type', 'Only response_type=code')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (
|
|
48
|
+
request.codeChallengeMethod !== 'S256' ||
|
|
49
|
+
!CODE_CHALLENGE.test(request.codeChallenge ?? '')
|
|
50
|
+
) {
|
|
51
|
+
throw new AuthorizationRequestError('invalid_request', 'PKCE with S256 is required')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// RFC 8707: the token is for one resource, and this server has one.
|
|
55
|
+
if (request.resource !== expectedResource) {
|
|
56
|
+
throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const authorization = new Authorization(
|
|
60
|
+
{
|
|
61
|
+
clientId: request.clientId,
|
|
62
|
+
clientName: client.clientName,
|
|
63
|
+
redirectUri: request.redirectUri,
|
|
64
|
+
codeChallenge: request.codeChallenge as string,
|
|
65
|
+
clientState: request.clientState,
|
|
66
|
+
resource: expectedResource,
|
|
67
|
+
},
|
|
68
|
+
new Date(),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
await this.authorizations.save(authorization)
|
|
72
|
+
|
|
73
|
+
return authorization
|
|
74
|
+
}
|
|
75
|
+
}
|