API tokens: mint, list, revoke, and revoke with what one created !170
7 files changed, +444 −2
Layout: unified · split
gitbay/Account/TokensViewModel.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One API token (`token list`): a name, what it may do, and when it was | |
| 5 | /// used and stops working. The secret is shown once, when it is minted. | |
| 6 | nonisolated struct APIToken: Decodable, Sendable, Hashable, Identifiable { | |
| 7 | let name: String | |
| 8 | let scope: String | |
| 9 | let createdAt: Date | |
| 10 | let expiresAt: Date? | |
| 11 | let lastUsedAt: Date? | |
| 12 | /// The token or key that minted it, when one did rather than you. | |
| 13 | let createdBy: String? | |
| 14 | ||
| 15 | enum CodingKeys: String, CodingKey { | |
| 16 | case name, scope | |
| 17 | case createdAt = "created_at" | |
| 18 | case expiresAt = "expires_at" | |
| 19 | case lastUsedAt = "last_used_at" | |
| 20 | case createdBy = "created_by" | |
| 21 | } | |
| 22 | ||
| 23 | var id: String { name } | |
| 24 | ||
| 25 | func isExpired(at now: Date) -> Bool { | |
| 26 | expiresAt.map { $0 <= now } ?? false | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | /// `token list`, `token create` and `token revoke [--created]`. | |
| 31 | @Observable | |
| 32 | @MainActor | |
| 33 | final class TokensViewModel { | |
| 34 | ||
| 35 | /// What a revoked token had minted: tokens by name, keys by fingerprint. | |
| 36 | nonisolated struct Created: Decodable, Sendable, Hashable { | |
| 37 | let tokens: [String]? | |
| 38 | let keys: [String]? | |
| 39 | var isEmpty: Bool { (tokens ?? []).isEmpty && (keys ?? []).isEmpty } | |
| 40 | } | |
| 41 | ||
| 42 | nonisolated private struct Minted: Decodable, Sendable { | |
| 43 | let token: String | |
| 44 | } | |
| 45 | ||
| 46 | nonisolated private struct Revoked: Decodable, Sendable { | |
| 47 | let created: Created | |
| 48 | } | |
| 49 | ||
| 50 | private(set) var state: LoadState<[APIToken]> = .loading | |
| 51 | private(set) var actionError: String? | |
| 52 | private(set) var working = false | |
| 53 | ||
| 54 | private let client: GitbayClient | |
| 55 | ||
| 56 | init(client: GitbayClient) { | |
| 57 | self.client = client | |
| 58 | } | |
| 59 | ||
| 60 | func load() async { | |
| 61 | do { | |
| 62 | let tokens = try await client.readList(["token", "list"], of: APIToken.self) | |
| 63 | state = tokens.isEmpty ? .empty("No API tokens.") : .loaded(tokens) | |
| 64 | } catch { | |
| 65 | state = .from(error) | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | /// `token create --name <n> --scope <s> [--ttl <d>]`. Returns the | |
| 70 | /// secret, which the server shows this once and never again. | |
| 71 | func create(name: String, scope: String, ttl: String?) async -> String? { | |
| 72 | var argv = ["token", "create", "--name", name.trimmingCharacters(in: .whitespaces), "--scope", scope] | |
| 73 | if let ttl { argv += ["--ttl", ttl] } | |
| 74 | let minted: Minted? = await perform { try await self.client.run(argv, as: Minted.self) } | |
| 75 | return minted?.token | |
| 76 | } | |
| 77 | ||
| 78 | /// `token revoke <name> [--created]`. Returns what the token had | |
| 79 | /// minted: revoked with it, or still in place. | |
| 80 | func revoke(_ token: APIToken, withCreated: Bool) async -> Created? { | |
| 81 | var argv = ["token", "revoke", token.name] | |
| 82 | if withCreated { argv.append("--created") } | |
| 83 | let revoked: Revoked? = await perform { try await self.client.run(argv, as: Revoked.self) } | |
| 84 | return revoked?.created | |
| 85 | } | |
| 86 | ||
| 87 | private func perform<T: Sendable>(_ body: () async throws -> T?) async -> T? { | |
| 88 | working = true | |
| 89 | actionError = nil | |
| 90 | defer { working = false } | |
| 91 | do { | |
| 92 | let result = try await body() | |
| 93 | await load() | |
| 94 | return result | |
| 95 | } catch let error as GitbayError { | |
| 96 | // The name is typed, so a usage refusal of it is shown. | |
| 97 | actionError = error.messageForTypedInput | |
| 98 | } catch { | |
| 99 | actionError = GitbayError.transport(error).userFacingMessage | |
| 100 | } | |
| 101 | return nil | |
| 102 | } | |
| 103 | } | |
gitbay/ContentView.swift +2
| @@ -337,6 +337,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 337 | 337 | ProfileView(client: client, name: name) |
| 338 | 338 | case .account: |
| 339 | 339 | AccountView(client: client) |
| 340 | case .tokens: | |
| 341 | TokensView(client: client) | |
| 340 | 342 | case .addAccount: |
| 341 | 343 | SignInView() |
| 342 | 344 | case .notifications: |
gitbay/Views/Account/AccountView.swift +2 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | /// SSH keys, PGP keys, and email verification. Tokens are minted on the | |
| 4 | /// web settings page or over SSH, not here. | |
| 3 | /// SSH keys, PGP keys, email verification and notification settings. | |
| 4 | /// API tokens have their own screen. | |
| 5 | 5 | struct AccountView: View { |
| 6 | 6 | |
| 7 | 7 | @State private var model: AccountViewModel |
gitbay/Views/Account/TokensView.swift added +227
| @@ -0,0 +1,227 @@ | ||
| 1 | import SwiftUI | |
| 2 | import UIKit | |
| 3 | import UniformTypeIdentifiers | |
| 4 | ||
| 5 | /// Your API tokens: what each may do, when it was used and stops | |
| 6 | /// working, and who minted it. Mint one from the + button; swipe to revoke. | |
| 7 | struct TokensView: View { | |
| 8 | ||
| 9 | @State private var model: TokensViewModel | |
| 10 | @State private var minting = false | |
| 11 | @State private var minted: String? | |
| 12 | @State private var revoking: APIToken? | |
| 13 | /// What the last revoked token had minted, and whether it went too. | |
| 14 | @State private var revokedReport: String? | |
| 15 | ||
| 16 | init(client: GitbayClient) { | |
| 17 | _model = State(initialValue: TokensViewModel(client: client)) | |
| 18 | } | |
| 19 | ||
| 20 | var body: some View { | |
| 21 | List { | |
| 22 | if let error = model.actionError, !minting { | |
| 23 | Section { GBNotice(error, .gbWarn) } | |
| 24 | } | |
| 25 | if let report = revokedReport { | |
| 26 | Section { GBNotice(report, .gbOK) } | |
| 27 | } | |
| 28 | ForEach(model.state.value ?? []) { token in | |
| 29 | row(token) | |
| 30 | .swipeActions { | |
| 31 | Button("Revoke", role: .destructive) { revoking = token } | |
| 32 | } | |
| 33 | .contextMenu { | |
| 34 | Button(role: .destructive) { | |
| 35 | revoking = token | |
| 36 | } label: { | |
| 37 | Label("Revoke", systemImage: "xmark.circle") | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | .overlay { LoadStateOverlay(state: model.state) } | |
| 43 | .navigationTitle("API Tokens") | |
| 44 | .navigationBarTitleDisplayMode(.inline) | |
| 45 | .toolbar { | |
| 46 | ToolbarItem(placement: .topBarTrailing) { | |
| 47 | Button { | |
| 48 | minting = true | |
| 49 | } label: { | |
| 50 | Image(systemName: "plus") | |
| 51 | } | |
| 52 | .accessibilityLabel("New Token") | |
| 53 | .accessibilityIdentifier("token-create") | |
| 54 | } | |
| 55 | } | |
| 56 | .confirmationDialog( | |
| 57 | revoking.map { "Revoke \($0.name)?" } ?? "", | |
| 58 | isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), | |
| 59 | titleVisibility: .visible, | |
| 60 | presenting: revoking | |
| 61 | ) { token in | |
| 62 | Button("Revoke", role: .destructive) { revoke(token, withCreated: false) } | |
| 63 | Button("Revoke with what it created", role: .destructive) { revoke(token, withCreated: true) } | |
| 64 | Button("Cancel", role: .cancel) {} | |
| 65 | } message: { _ in | |
| 66 | Text("Anything using it stops working at once. What it created, tokens and keys, stays unless you revoke it too. If this app signed in with it, or with one of those, the app signs out.") | |
| 67 | } | |
| 68 | .sheet(isPresented: $minting, onDismiss: { minted = nil }) { | |
| 69 | MintTokenSheet(model: model, minted: $minted) | |
| 70 | } | |
| 71 | .task { await model.load() } | |
| 72 | .refreshable { await model.load() } | |
| 73 | } | |
| 74 | ||
| 75 | private func row(_ token: APIToken) -> some View { | |
| 76 | VStack(alignment: .leading, spacing: 3) { | |
| 77 | HStack(spacing: 6) { | |
| 78 | Text(token.name) | |
| 79 | .font(.gbSans(.subheadline).weight(.semibold)) | |
| 80 | GBChip(token.scope, .secondary) | |
| 81 | if let by = token.createdBy { | |
| 82 | Text("by \(by)") | |
| 83 | .font(.gbSans(.caption2)) | |
| 84 | .foregroundStyle(.secondary) | |
| 85 | } | |
| 86 | } | |
| 87 | HStack(spacing: 4) { | |
| 88 | if let used = token.lastUsedAt { | |
| 89 | Text("used \(used.formatted(.relative(presentation: .named)))") | |
| 90 | } else { | |
| 91 | Text("never used") | |
| 92 | } | |
| 93 | Text("·") | |
| 94 | if let expires = token.expiresAt { | |
| 95 | if token.isExpired(at: .now) { | |
| 96 | Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") | |
| 97 | .foregroundStyle(Color.gbBad) | |
| 98 | } else { | |
| 99 | Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") | |
| 100 | } | |
| 101 | } else { | |
| 102 | Text("does not expire") | |
| 103 | } | |
| 104 | } | |
| 105 | .font(.gbSans(.caption2)) | |
| 106 | .foregroundStyle(.secondary) | |
| 107 | } | |
| 108 | .padding(.vertical, 2) | |
| 109 | .accessibilityElement(children: .combine) | |
| 110 | .accessibilityIdentifier("token-\(token.name)") | |
| 111 | } | |
| 112 | ||
| 113 | private func revoke(_ token: APIToken, withCreated: Bool) { | |
| 114 | revokedReport = nil | |
| 115 | Task { | |
| 116 | guard let created = await model.revoke(token, withCreated: withCreated) else { return } | |
| 117 | let made = (created.tokens ?? []).map { "token \($0)" } + (created.keys ?? []).map { "key \($0)" } | |
| 118 | if made.isEmpty { | |
| 119 | revokedReport = "Revoked \(token.name)." | |
| 120 | } else if withCreated { | |
| 121 | revokedReport = "Revoked \(token.name), and what it created: " + made.joined(separator: ", ") + "." | |
| 122 | } else { | |
| 123 | revokedReport = "Revoked \(token.name). It created these, still in place: " + made.joined(separator: ", ") + "." | |
| 124 | } | |
| 125 | } | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | /// Name, scope and lifetime, then the token itself, shown this once. | |
| 130 | private struct MintTokenSheet: View { | |
| 131 | ||
| 132 | let model: TokensViewModel | |
| 133 | @Binding var minted: String? | |
| 134 | ||
| 135 | @State private var name = "" | |
| 136 | /// read, as the server mints by default; full is a choice. | |
| 137 | @State private var scope = "read" | |
| 138 | @State private var ttl: String? | |
| 139 | @State private var sent = false | |
| 140 | @State private var copied = false | |
| 141 | @Environment(\.dismiss) private var dismiss | |
| 142 | ||
| 143 | var body: some View { | |
| 144 | NavigationStack { | |
| 145 | Form { | |
| 146 | if let minted { | |
| 147 | Section { | |
| 148 | Text(minted) | |
| 149 | .font(.gbMono(.subheadline)) | |
| 150 | .textSelection(.enabled) | |
| 151 | .accessibilityIdentifier("token-secret") | |
| 152 | Button(copied ? "Copied" : "Copy") { | |
| 153 | // This device only, and gone in two minutes: a | |
| 154 | // token is not for Universal Clipboard or for | |
| 155 | // whatever is pasted next week. | |
| 156 | UIPasteboard.general.setItems( | |
| 157 | [[UTType.utf8PlainText.identifier: minted]], | |
| 158 | options: [.localOnly: true, .expirationDate: Date.now.addingTimeInterval(120)]) | |
| 159 | copied = true | |
| 160 | } | |
| 161 | .accessibilityIdentifier("token-copy") | |
| 162 | } header: { | |
| 163 | Text("\(name) · \(scope)") | |
| 164 | } footer: { | |
| 165 | Text("This is the only time it is shown. Store it now.") | |
| 166 | } | |
| 167 | } else { | |
| 168 | Section("Name") { | |
| 169 | TextField("laptop", text: $name) | |
| 170 | .autocorrectionDisabled() | |
| 171 | .textInputAutocapitalization(.never) | |
| 172 | .accessibilityIdentifier("token-name") | |
| 173 | } | |
| 174 | Section { | |
| 175 | Picker("Scope", selection: $scope) { | |
| 176 | Text("full").tag("full") | |
| 177 | Text("read").tag("read") | |
| 178 | } | |
| 179 | .pickerStyle(.segmented) | |
| 180 | Picker("Expires", selection: $ttl) { | |
| 181 | Text("Never").tag(String?.none) | |
| 182 | Text("In 30 days").tag(String?.some("30d")) | |
| 183 | Text("In 90 days").tag(String?.some("90d")) | |
| 184 | Text("In a year").tag(String?.some("365d")) | |
| 185 | } | |
| 186 | } footer: { | |
| 187 | Text("Full scope can change anything you can; read can only look. An expiring token cannot mint tokens or keys.") | |
| 188 | } | |
| 189 | if sent, let error = model.actionError { | |
| 190 | Section { GBNotice(error) } | |
| 191 | } | |
| 192 | } | |
| 193 | } | |
| 194 | .navigationTitle(minted == nil ? "New Token" : "Your Token") | |
| 195 | .navigationBarTitleDisplayMode(.inline) | |
| 196 | .toolbar { | |
| 197 | if minted != nil { | |
| 198 | ToolbarItem(placement: .confirmationAction) { | |
| 199 | Button("Done") { dismiss() } | |
| 200 | .accessibilityIdentifier("token-done") | |
| 201 | } | |
| 202 | } else { | |
| 203 | ToolbarItem(placement: .cancellationAction) { | |
| 204 | // Not while minting: the token would be made and | |
| 205 | // never shown. | |
| 206 | Button("Cancel") { dismiss() } | |
| 207 | .disabled(model.working) | |
| 208 | } | |
| 209 | ToolbarItem(placement: .confirmationAction) { | |
| 210 | if model.working { | |
| 211 | ProgressView() | |
| 212 | } else { | |
| 213 | Button("Create") { | |
| 214 | sent = true | |
| 215 | Task { minted = await model.create(name: name, scope: scope, ttl: ttl) } | |
| 216 | } | |
| 217 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 218 | .accessibilityIdentifier("token-submit") | |
| 219 | } | |
| 220 | } | |
| 221 | } | |
| 222 | } | |
| 223 | // The token is on screen once; a swipe must not lose it. | |
| 224 | .interactiveDismissDisabled(model.working || minted != nil) | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
gitbay/Views/Repos/RepoListView.swift +3
| @@ -268,6 +268,9 @@ struct AccountMenu: ToolbarContent { | ||
| 268 | 268 | NavigationLink(value: RepoRoute.account) { |
| 269 | 269 | Label("Keys & Email", systemImage: "key") |
| 270 | 270 | } |
| 271 | NavigationLink(value: RepoRoute.tokens) { | |
| 272 | Label("API Tokens", systemImage: "key.horizontal") | |
| 273 | } | |
| 271 | 274 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 272 | 275 | Label("Snippets", systemImage: "scissors") |
| 273 | 276 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -27,6 +27,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 27 | 27 | case webhookDeliveries(repo: String) |
| 28 | 28 | case profile(String) |
| 29 | 29 | case account |
| 30 | case tokens | |
| 30 | 31 | case addAccount |
| 31 | 32 | case notifications |
| 32 | 33 | case admin |
gitbayTests/TokenTests.swift added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func posted(_ stub: StubProtocol.Box) throws -> [[String]] { | |
| 16 | try stub.seen.filter { $0.method == "POST" }.map { seen in | |
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 18 | return try #require(body["argv"] as? [String]) | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | private let listJSON = """ | |
| 23 | {"protocol_version":1,"data":[\ | |
| 24 | {"name":"iphone","scope":"full","created_at":"2026-09-01T10:00:00.000Z","last_used_at":"2026-10-14T07:00:00Z"},\ | |
| 25 | {"name":"ci","scope":"read","created_at":"2026-09-02T10:00:00.000Z","expires_at":"2026-01-01T00:00:00Z","created_by":"iphone"}\ | |
| 26 | ],"exit_code":0} | |
| 27 | """ | |
| 28 | ||
| 29 | @MainActor | |
| 30 | struct TokenTests { | |
| 31 | ||
| 32 | private func loaded() async throws -> (TokensViewModel, StubProtocol.Box) { | |
| 33 | let (client, stub) = try makeClient() | |
| 34 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 35 | let model = TokensViewModel(client: client) | |
| 36 | await model.load() | |
| 37 | return (model, stub) | |
| 38 | } | |
| 39 | ||
| 40 | @Test func tokensListWithUseExpiryAndWhoMadeThem() async throws { | |
| 41 | let (model, _) = try await loaded() | |
| 42 | let tokens = try #require(model.state.value) | |
| 43 | #expect(tokens.map(\.name) == ["iphone", "ci"]) | |
| 44 | #expect(tokens[0].lastUsedAt != nil) | |
| 45 | #expect(tokens[1].createdBy == "iphone") | |
| 46 | #expect(tokens[1].isExpired(at: .now)) | |
| 47 | } | |
| 48 | ||
| 49 | @Test func mintingReturnsTheTokenOnceAndReloads() async throws { | |
| 50 | let (model, stub) = try await loaded() | |
| 51 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"laptop","scope":"read","token":"gbt_secret"},"exit_code":0}"#, match: "cmd")) | |
| 52 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 53 | ||
| 54 | let minted = await model.create(name: " laptop ", scope: "read", ttl: "30d") | |
| 55 | ||
| 56 | #expect(minted == "gbt_secret") | |
| 57 | #expect(try posted(stub) == [["token", "create", "--name", "laptop", "--scope", "read", "--ttl", "30d"]]) | |
| 58 | } | |
| 59 | ||
| 60 | @Test func aTokenThatNeverExpiresSendsNoTTL() async throws { | |
| 61 | let (model, stub) = try await loaded() | |
| 62 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"x","scope":"full","token":"t"},"exit_code":0}"#, match: "cmd")) | |
| 63 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 64 | ||
| 65 | _ = await model.create(name: "x", scope: "full", ttl: nil) | |
| 66 | ||
| 67 | #expect(try posted(stub) == [["token", "create", "--name", "x", "--scope", "full"]]) | |
| 68 | } | |
| 69 | ||
| 70 | /// An expiring token cannot mint one; the server says so, and the | |
| 71 | /// app's sentence for that refusal says what to do instead. | |
| 72 | @Test func anExpiringSessionIsToldWhy() async throws { | |
| 73 | let (model, stub) = try await loaded() | |
| 74 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"token create creates a credential, and the one this request came with expires; use a token or key without an expiry","exit_code":4}"#, match: "cmd")) | |
| 75 | ||
| 76 | #expect(await model.create(name: "x", scope: "full", ttl: nil) == nil) | |
| 77 | #expect(model.actionError?.contains("without an expiry") == true) | |
| 78 | } | |
| 79 | ||
| 80 | @Test func revokingCanTakeWhatTheTokenCreated() async throws { | |
| 81 | let (model, stub) = try await loaded() | |
| 82 | stub.enqueue(.init(status: 200, json: """ | |
| 83 | {"protocol_version":1,"data":{"revoked":"iphone","created":{"tokens":["ci"],"keys":["SHA256:k"]},"created_revoked":true},"exit_code":0} | |
| 84 | """, match: "cmd")) | |
| 85 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 86 | let token = try #require(model.state.value?.first) | |
| 87 | ||
| 88 | let created = await model.revoke(token, withCreated: true) | |
| 89 | ||
| 90 | #expect(try posted(stub) == [["token", "revoke", "iphone", "--created"]]) | |
| 91 | #expect(created?.tokens == ["ci"]) | |
| 92 | #expect(created?.keys == ["SHA256:k"]) | |
| 93 | } | |
| 94 | ||
| 95 | @Test func revokingAloneLeavesWhatItCreated() async throws { | |
| 96 | let (model, stub) = try await loaded() | |
| 97 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"revoked":"ci","created":{"tokens":null,"keys":null},"created_revoked":false},"exit_code":0}"#, match: "cmd")) | |
| 98 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 99 | let token = try #require(model.state.value?.last) | |
| 100 | ||
| 101 | let created = await model.revoke(token, withCreated: false) | |
| 102 | ||
| 103 | #expect(try posted(stub) == [["token", "revoke", "ci"]]) | |
| 104 | #expect(created?.isEmpty == true) | |
| 105 | } | |
| 106 | } | |