-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathServices.swift
More file actions
106 lines (96 loc) · 4.83 KB
/
Copy pathServices.swift
File metadata and controls
106 lines (96 loc) · 4.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//===----------------------------------------------------------------------===//
// What a worker holds: the database pool, the keys that sign access tokens,
// and the issuer that hands out token pairs.
//
// One state value rather than three, because they are made together and a
// route usually wants more than one. Garuda builds it in each worker after the
// fork (`app.state`), so nothing here is shared between processes and nothing
// needs a lock: a worker is one process with one thread.
//
// A handler reaches it with `State<Services>`, and a start-up hook with
// `start.state(Services.self)`.
//===----------------------------------------------------------------------===//
import Garuda
public final class Services {
public let configuration: StarterConfiguration
public let pool: PostgresPool
public let keys: JWTKeys
public let issuer: TokenIssuer<AccessClaims>
public init(_ configuration: StarterConfiguration) throws {
self.configuration = configuration
let pool = PostgresPool(try PostgresConfiguration(url: configuration.databaseURL),
maxConnections: configuration.databasePoolSize)
self.pool = pool
keys = try Services.keys(for: configuration)
issuer = TokenIssuer(keys: keys, store: PostgresRefreshTokenStore(pool),
accessTokenSeconds: configuration.accessTokenSeconds,
refreshTokenSeconds: configuration.refreshTokenDays * 24 * 3600,
maximumSessionSeconds: configuration.sessionDays * 24 * 3600) { subject, lifetime in
// Built at every login and every refresh, against the database as
// it is now: an email that changed, or a role taken away, is in
// the next access token and so gone within one token's life.
let row = try await pool.first(AccountRow.self, "select id, email, role from users where id = $1",
Int64(subject) ?? -1)
guard let row else { throw HTTPError(.unauthorized, "that account no longer exists") }
return AccessClaims(sub: subject, email: row.email, role: row.role, iat: lifetime.issuedAt,
exp: lifetime.expiresAt, jti: lifetime.tokenID)
}
}
/// A hash of a password nobody has. A login for an unknown email is
/// verified against it, so answering takes the same time as for an email
/// that exists. Made once per worker in `app.prepare`, because hashing
/// costs a few hundred milliseconds of CPU.
public private(set) var absentPasswordHash = ""
/// The work a worker does once, before it serves: the schema, and the
/// hash above.
public func warmUp() async throws {
try await pool.migrate(starterMigrations)
try await promoteAdministrators()
absentPasswordHash = try await Passwords.hash(Tokens.random())
}
/// Makes every account in `ADMIN_EMAILS` an administrator.
///
/// A new database has no administrator, and a route that promotes whoever
/// asks is not a route, so the first one is named by the deployment. The
/// list is a floor and not the whole truth: it only ever promotes, because
/// a list that also demoted would undo an administrator's work at the next
/// restart. To demote one of these accounts, take it out of the list first.
///
/// Idempotent, and every worker runs it: the `where` clause means only the
/// first to arrive writes anything.
private func promoteAdministrators() async throws {
let emails = configuration.adminEmails
guard !emails.isEmpty else { return }
let promoted = try await pool.execute(
"update users set role = 'admin' where email = any($1) and role <> 'admin'", emails)
if promoted > 0 {
AppLog.info("promoted administrators", ["accounts": "\(promoted)"])
}
}
/// Closes what the worker opened. `app.state`'s shutdown calls it.
public func close() {
pool.close()
}
}
/// The claims an access token carries. `sub` is the user's id as text, as JWT
/// has it.
public struct AccessClaims: Codable, Sendable {
public let sub: String
public let email: String
/// What the account may do, as of when the token was issued. Carried in
/// the token so a route can check it without a lookup, which is what
/// bounds how stale it can be: `accessTokenSeconds`, and less than that
/// when the change also ends their sessions (Admin.swift).
public let role: String
public let iat: Int
public let exp: Int
/// The token's own id, so one can be named in a log without the token
/// itself appearing there.
public let jti: String
public var userID: Int64? { Int64(sub) }
}
struct AccountRow: Decodable {
let id: Int64
let email: String
let role: String
}