Add anonymous OOBE gateway grants
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide App Attest-bound, one-time onboarding AI access without creating accounts, with durable replay protection and production deployment safeguards.
This commit is contained in:
Rocky
2026-08-21 22:55:46 +08:00
parent edd0d9feca
commit 0d236f57fb
32 changed files with 2202 additions and 61 deletions
+2
View File
@@ -58,6 +58,8 @@ APPLE_TOKEN_URL=https://appleid.apple.com/auth/token
APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke
APPLE_INTEGRITY_ENVIRONMENT=development APPLE_INTEGRITY_ENVIRONMENT=development
APP_ATTEST_CHALLENGE_TTL_SECONDS=300 APP_ATTEST_CHALLENGE_TTL_SECONDS=300
# Temporary production-device testing only; keep false for normal deployments.
ALLOW_DEVELOPMENT_APP_ATTEST=false
# DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key. # DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key.
# Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional. # Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional.
+2
View File
@@ -89,6 +89,8 @@ jobs:
with: with:
context: . context: .
push: true push: true
build-args: |
APP_BUILD_SHA=${{ github.sha }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha cache-from: type=gha
+2
View File
@@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
./gradlew --no-daemon --no-configuration-cache --stacktrace installDist ./gradlew --no-daemon --no-configuration-cache --stacktrace installDist
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre-alpine
ARG APP_BUILD_SHA=unknown
RUN addgroup -S -g 10001 app \ RUN addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app -h /app app && adduser -S -D -H -u 10001 -G app -h /app app
WORKDIR /app WORKDIR /app
@@ -17,6 +18,7 @@ WORKDIR /app
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/ COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
ENV HOME=/tmp \ ENV HOME=/tmp \
APP_BUILD_SHA=$APP_BUILD_SHA \
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
USER 10001:10001 USER 10001:10001
+5
View File
@@ -92,6 +92,11 @@ receipt, assertion, and certificate-chain validation use the
official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the
validated public key, receipt, and strictly increasing assertion counter. validated public key, receipt, and strictly increasing assertion counter.
Production accepts only production App Attest AAGUIDs by default. For a time-bounded physical-device
test against the production service, set `ALLOW_DEVELOPMENT_APP_ATTEST=true` to admit development
AAGUIDs from registered development builds. Disable the flag again after testing; TestFlight and App
Store builds do not require it.
Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR
object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256 object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256
of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire
+1
View File
@@ -52,6 +52,7 @@ services:
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM} APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
APPLE_INTEGRITY_ENVIRONMENT: production APPLE_INTEGRITY_ENVIRONMENT: production
APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300} APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300}
ALLOW_DEVELOPMENT_APP_ATTEST: ${ALLOW_DEVELOPMENT_APP_ATTEST:-false}
ENFORCE_DEVICE_CHECK: "true" ENFORCE_DEVICE_CHECK: "true"
ENFORCE_APP_ATTEST: "true" ENFORCE_APP_ATTEST: "true"
+11 -4
View File
@@ -258,8 +258,15 @@ grant_pattern = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
expected = set() expected = set()
for raw_line in open(sys.argv[1], encoding="utf-8"): with open(sys.argv[1], encoding="utf-8") as grants_file:
match = grant_pattern.match(raw_line.strip()) statements = grants_file.read().split(";")
for statement in statements:
normalized = " ".join(
line.strip()
for line in statement.splitlines()
if line.strip() and not line.lstrip().startswith("--")
)
match = grant_pattern.match(f"{normalized};")
if match: if match:
for privilege in match.group(1).split(","): for privilege in match.group(1).split(","):
expected.add((match.group(2).lower(), privilege.strip().upper())) expected.add((match.group(2).lower(), privilege.strip().upper()))
@@ -470,9 +477,9 @@ WHERE version IS NOT NULL
ORDER BY installed_rank; ORDER BY installed_rank;
SQL SQL
)" )"
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1\n9:1\n10:1\n11:1\n12:1\n13:1\n14:1\n15:1\n16:1\n17:1' EXPECTED_MIGRATIONS="$(seq 1 26 | awk '{ print $1 ":1" }')"
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] || [[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
fail "Flyway history was not exactly successful V1-V17" fail "Flyway history was not exactly successful V1-V26"
REFERRAL_REWARDS="$( REFERRAL_REWARDS="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL' mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits) SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
+10
View File
@@ -19,6 +19,11 @@ GRANT SELECT ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.gateway_complimentary_requests TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.gateway_complimentary_requests TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
@@ -60,6 +65,11 @@ GRANT INSERT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%
GRANT INSERT, UPDATE ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests
TO 'osg_smoke_runtime'@'%'; TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
+4
View File
@@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8
v3 WSS endpoint、资源 ID 和 API KeyDeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。 v3 WSS endpoint、资源 ID 和 API KeyDeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
三方凭据分别创建、分别轮换,不得复用。 三方凭据分别创建、分别轮换,不得复用。
生产默认仅接受 TestFlight 与 App Store 构建的 production App Attest。确需让已登记真机上的
Xcode Development 构建连接生产服务时,可临时设置
`ALLOW_DEVELOPMENT_APP_ATTEST=true`;完成测试后应立即恢复为 `false` 并重启服务。
## 5. 构建与启动 ## 5. 构建与启动
GitHub CI 在测试通过后发布私有镜像 GitHub CI 在测试通过后发布私有镜像
+10
View File
@@ -31,6 +31,11 @@ GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.gateway_complimentary_requests TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_complimentary_requests TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
@@ -72,6 +77,11 @@ GRANT INSERT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20
GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests
TO 'osg_account_runtime'@'10.20.%'; TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
+86 -5
View File
@@ -381,6 +381,43 @@ paths:
responses: responses:
"200": { description: Assertion counter advanced } "200": { description: Assertion counter advanced }
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/oobe/grants:
post:
security: []
summary: Create a short-lived anonymous OOBE gateway grant
description: |
Verifies an App Attest assertion bound to the installation and returns
credentials limited to the four one-time onboarding AI features.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateOobeGrantRequest" }
responses:
"201":
description: OOBE gateway credentials
content:
application/json:
schema: { $ref: "#/components/schemas/OobeGrantTokens" }
default: { $ref: "#/components/responses/GatewayError" }
/v1/oobe/grants/refresh:
post:
security: []
summary: Rotate an anonymous OOBE refresh token
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RefreshOobeGrantRequest" }
responses:
"200":
description: Rotated OOBE gateway credentials
content:
application/json:
schema: { $ref: "#/components/schemas/OobeGrantTokens" }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/catalog: /v1/gateway/catalog:
get: get:
summary: Return configured managed-provider capabilities summary: Return configured managed-provider capabilities
@@ -441,9 +478,10 @@ paths:
and output-budget policy from `capability` plus optional `taskKind`. It and output-budget policy from `capability` plus optional `taskKind`. It
never infers task type from `input` or `context`, and clients cannot never infers task type from `input` or `context`, and clients cannot
supply provider parameters. Search and tools are currently disabled. supply provider parameters. Search and tools are currently disabled.
An authenticated `oobe` purpose is accepted only for dictation polish. For account grants, `oobe` is accepted only for dictation polish and the
The first successful request per account is complimentary; later attempts first successful request per account is complimentary. Anonymous OOBE
fail without falling through to paid billing. grants require a matching `oobeFeature` and allow one successful request
per feature. Later attempts fail without falling through to paid billing.
parameters: parameters:
- $ref: "#/components/parameters/RequestId" - $ref: "#/components/parameters/RequestId"
- name: capability - name: capability
@@ -2629,8 +2667,51 @@ components:
type: ["string", "null"] type: ["string", "null"]
enum: [oobe, null] enum: [oobe, null]
description: | description: |
Optional server-audited billing purpose. `oobe` is valid only with Optional server-audited billing purpose. Account grants accept `oobe`
`polish` and `dictation_polish`, and is complimentary once per account. only for complimentary dictation polish. Anonymous OOBE grants require
`oobe` together with an `oobeFeature`.
oobeFeature:
type: ["string", "null"]
enum: [voice_input, clipboard_translate, clipboard_reply, ask_ai, null]
description: |
Required for anonymous OOBE grants. The server validates that the
feature matches the requested capability and task kind, and allows
each feature to succeed only once per installation-bound subject.
CreateOobeGrantRequest:
type: object
additionalProperties: false
required: [challengeId, challenge, keyId, installationId, assertion]
properties:
challengeId: { type: string, format: uuid }
challenge: { type: string, description: Base64URL challenge returned by the integrity API }
keyId: { type: string, minLength: 1, maxLength: 256 }
installationId: { type: string, format: uuid }
assertion: { type: string, contentEncoding: base64 }
RefreshOobeGrantRequest:
type: object
additionalProperties: false
required: [refreshToken]
properties:
refreshToken: { type: string, minLength: 32, maxLength: 512 }
OobeGrantTokens:
type: object
additionalProperties: false
required:
[grantId, scopes, features, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt]
properties:
grantId: { type: string, format: uuid }
scopes:
type: array
uniqueItems: true
items: { type: string, enum: [polish, ai] }
features:
type: array
uniqueItems: true
items: { type: string, enum: [voice_input, clipboard_translate, clipboard_reply, ask_ai] }
accessToken: { type: string }
accessExpiresAt: { type: string, format: date-time }
refreshToken: { type: string }
refreshExpiresAt: { type: string, format: date-time }
CreateGatewayGrantRequest: CreateGatewayGrantRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -131,6 +131,11 @@ import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.InviteOpenRecorder import com.osglab.account.features.inviteweb.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.oobe.ExposedOobeRepository
import com.osglab.account.features.oobe.OobeGrantService
import com.osglab.account.features.oobe.OobeRepository
import com.osglab.account.features.oobe.OobeTokenSettings
import com.osglab.account.features.oobe.oobeRoutes
import com.osglab.account.features.referrals.routes.referralRoutes import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService import com.osglab.account.features.referrals.services.ReferralService
@@ -329,6 +334,7 @@ fun Application.module() {
healthRoutes(koin.get()) healthRoutes(koin.get())
rateLimit(AUTH_RATE_LIMIT) { rateLimit(AUTH_RATE_LIMIT) {
authRoutes(koin.get()) authRoutes(koin.get())
oobeRoutes(koin.get())
} }
rateLimit(ACCOUNT_RATE_LIMIT) { rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get()) accountRoutes(koin.get())
@@ -652,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module {
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays), maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
) )
} }
single<OobeRepository> { ExposedOobeRepository(get()) }
single {
OobeTokenSettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-access",
),
refreshTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-refresh",
),
)
}
single { OobeGrantService(get(), get(), get()) }
single { GatewayGrantService(get(), get()) } single { GatewayGrantService(get(), get()) }
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) } single<GatewayAccessTokenPort> {
val oobeGrants = get<OobeGrantService>()
GatewayBearerIdentity(get(), oobeGrants::authenticate)
}
single<CreditReservationPort> { single<CreditReservationPort> {
CreditReservationAdapter( CreditReservationAdapter(
creditService = get(), creditService = get(),
@@ -664,7 +689,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single { single {
ProviderCatalog(configuredProviders(config, get())) ProviderCatalog(configuredProviders(config, get()))
} }
single { GatewayService(get(), get(), get(), get(), get()) } single { GatewayService(get(), get(), get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) } single { GatewayReconciliationService(get(), get()) }
single { single {
InviteWebConfig( InviteWebConfig(
@@ -148,6 +148,10 @@ data class AppConfig(
if (production) "production" else "development", if (production) "production" else "development",
), ),
), ),
allowDevelopmentAppAttest = config.booleanOrDefault(
"app.integrity.allowDevelopmentAppAttest",
false,
),
challengeLifetimeSeconds = config.positiveLong( challengeLifetimeSeconds = config.positiveLong(
"app.integrity.challengeLifetimeSeconds", "app.integrity.challengeLifetimeSeconds",
300, 300,
@@ -449,6 +453,7 @@ data class IntegrityConfig(
val deviceCheckPolicy: IntegrityPolicy, val deviceCheckPolicy: IntegrityPolicy,
val appAttestPolicy: IntegrityPolicy, val appAttestPolicy: IntegrityPolicy,
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT, val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
val allowDevelopmentAppAttest: Boolean = false,
val challengeLifetimeSeconds: Long = 300, val challengeLifetimeSeconds: Long = 300,
val appAttestTeamId: String = APP_ATTEST_TEAM_ID, val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID, val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
@@ -31,6 +31,26 @@ enum class GatewayRequestPurpose {
OOBE, OOBE,
} }
enum class GatewaySubjectType {
ACCOUNT,
OOBE,
}
@Serializable
enum class OobeFeature {
@SerialName("voice_input")
VOICE_INPUT,
@SerialName("clipboard_translate")
CLIPBOARD_TRANSLATE,
@SerialName("clipboard_reply")
CLIPBOARD_REPLY,
@SerialName("ask_ai")
ASK_AI,
}
@Serializable @Serializable
enum class UsageMeter { enum class UsageMeter {
@SerialName("llm_token") @SerialName("llm_token")
@@ -50,10 +70,14 @@ data class GatewayPrincipal(
// Callers must grant capabilities explicitly. An identity with omitted // Callers must grant capabilities explicitly. An identity with omitted
// scopes is intentionally unable to invoke a managed provider. // scopes is intentionally unable to invoke a managed provider.
val scopes: Set<GatewayCapability> = emptySet(), val scopes: Set<GatewayCapability> = emptySet(),
val subjectType: GatewaySubjectType = GatewaySubjectType.ACCOUNT,
) { ) {
// Kept as a compatibility name for the existing account-scoped persistence. // Kept as a compatibility name for the existing account-scoped persistence.
val accountId: String val accountId: String
get() = userId get() = userId
val isOobe: Boolean
get() = subjectType == GatewaySubjectType.OOBE
} }
typealias GatewaySubject = GatewayPrincipal typealias GatewaySubject = GatewayPrincipal
@@ -68,6 +92,7 @@ data class TextGatewayRequest(
val requestSource: GatewayRequestSource? = null, val requestSource: GatewayRequestSource? = null,
val taskKind: GatewayTaskKind? = null, val taskKind: GatewayTaskKind? = null,
val requestPurpose: GatewayRequestPurpose? = null, val requestPurpose: GatewayRequestPurpose? = null,
val oobeFeature: OobeFeature? = null,
) )
@Serializable @Serializable
@@ -176,6 +201,7 @@ data class TextProviderRequest(
val stream: Boolean, val stream: Boolean,
override val requestSource: GatewayRequestSource? = null, override val requestSource: GatewayRequestSource? = null,
override val requestPurpose: GatewayRequestPurpose? = null, override val requestPurpose: GatewayRequestPurpose? = null,
val oobeFeature: OobeFeature? = null,
) : ProviderRequest ) : ProviderRequest
data class AsrProviderRequest( data class AsrProviderRequest(
@@ -31,6 +31,8 @@ import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidEx
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -150,7 +152,15 @@ fun Route.configureGatewayRoutes(
get("/catalog") { get("/catalog") {
val requestId = call.gatewayRequestId() val requestId = call.gatewayRequestId()
call.requireSubject(gatewayIdentity, requestId) ?: return@get val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@get
if (subject.isOobe) {
return@get call.respondGatewayError(
HttpStatusCode.Forbidden,
"oobe_request_required",
"OOBE tokens are limited to OOBE LLM requests",
requestId,
)
}
call.respond(GatewayCatalogResponse(service.catalog())) call.respond(GatewayCatalogResponse(service.catalog()))
} }
@@ -276,6 +286,7 @@ fun Route.configureGatewayRoutes(
stream = body.stream, stream = body.stream,
requestSource = body.requestSource, requestSource = body.requestSource,
requestPurpose = body.requestPurpose, requestPurpose = body.requestPurpose,
oobeFeature = body.oobeFeature,
) )
if (body.stream) { if (body.stream) {
@@ -444,6 +455,20 @@ private suspend fun ApplicationCall.respondGatewayFailure(
requestId, requestId,
) )
is OobeFeatureAlreadyUsedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_feature_already_used",
"This OOBE feature has already been used successfully",
requestId,
)
is OobeRequestAlreadyClaimedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_request_replayed",
"This OOBE request ID has already been used",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError( is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge, HttpStatusCode.PayloadTooLarge,
"request_too_large", "request_too_large",
@@ -226,6 +226,7 @@ class GatewayGrantService(
class GatewayBearerIdentity( class GatewayBearerIdentity(
private val grants: GatewayGrantService, private val grants: GatewayGrantService,
private val authenticateOobe: suspend (String) -> GatewayPrincipal? = { null },
) : GatewayAccessTokenPort { ) : GatewayAccessTokenPort {
override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? { override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? {
val token = call.request.headers[HttpHeaders.Authorization] val token = call.request.headers[HttpHeaders.Authorization]
@@ -234,7 +235,7 @@ class GatewayBearerIdentity(
?.trim() ?.trim()
?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS } ?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS }
?: return null ?: return null
return grants.authenticate(token) return grants.authenticate(token) ?: authenticateOobe(token)
} }
private companion object { private companion object {
@@ -21,10 +21,16 @@ import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.oobe.OobeContract
import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeProviderRequest
import com.osglab.account.features.oobe.OobeRepository
import com.osglab.account.features.oobe.OobeRequestClaim
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.time.Clock
import kotlin.time.TimeSource import kotlin.time.TimeSource
class GatewayService( class GatewayService(
@@ -33,9 +39,11 @@ class GatewayService(
private val grants: GatewayGrantPort, private val grants: GatewayGrantPort,
private val usageRecords: GatewayUsagePort, private val usageRecords: GatewayUsagePort,
private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests, private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests,
private val oobeRequests: OobeRepository? = null,
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator, private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
private val llmProviderTimeoutMillis: Long = 120_000L, private val llmProviderTimeoutMillis: Long = 120_000L,
private val asrProviderTimeoutMillis: Long = 360_000L, private val asrProviderTimeoutMillis: Long = 360_000L,
private val clock: Clock = Clock.systemUTC(),
) { ) {
init { init {
require(llmProviderTimeoutMillis > 0) require(llmProviderTimeoutMillis > 0)
@@ -59,14 +67,39 @@ class GatewayService(
if (request.capability !in subject.scopes) { if (request.capability !in subject.scopes) {
throw GatewayAccessDeniedException(request.capability) throw GatewayAccessDeniedException(request.capability)
} }
if (!grants.isAllowed(subject.accountId, request.capability)) { if (!subject.isOobe && request is TextProviderRequest && request.oobeFeature != null) {
throw GatewayAccessDeniedException(request.capability)
}
if (subject.isOobe) {
validateAnonymousOobeRequest(request)
} else if (!grants.isAllowed(subject.accountId, request.capability)) {
throw GatewayAccessDeniedException(request.capability) throw GatewayAccessDeniedException(request.capability)
} }
val provider = catalog.providerFor(request) val provider = catalog.providerFor(request)
val estimate = usageEstimator.estimate(request) val estimate = usageEstimator.estimate(request)
validateEstimate(request, estimate) validateEstimate(request, estimate)
val complimentaryClaim = request.requestPurpose?.let { purpose -> val oobeClaim = if (subject.isOobe) {
val textRequest = request as TextProviderRequest
val feature = requireNotNull(textRequest.oobeFeature)
val now = clock.instant()
requireNotNull(oobeRequests).claim(
OobeProviderRequest(
subjectId = subject.userId,
grantId = requireNotNull(subject.grantId),
feature = feature,
requestId = request.requestId,
providerId = provider.descriptor.id,
capability = request.capability,
purpose = GatewayRequestPurpose.OOBE,
),
expiresAt = now.plus(OOBE_CLAIM_TTL),
now = now,
) ?: throw OobeFeatureAlreadyUsedException(feature)
} else {
null
}
val complimentaryClaim = request.requestPurpose?.takeUnless { subject.isOobe }?.let { purpose ->
validateComplimentaryRequest(request, purpose) validateComplimentaryRequest(request, purpose)
complimentaryRequests.claim( complimentaryRequests.claim(
accountId = subject.accountId, accountId = subject.accountId,
@@ -75,7 +108,7 @@ class GatewayService(
requestId = request.requestId, requestId = request.requestId,
) ?: throw ComplimentaryRequestUnavailableException(purpose) ) ?: throw ComplimentaryRequestUnavailableException(purpose)
} }
val reservation = if (complimentaryClaim == null) { val reservation = if (complimentaryClaim == null && oobeClaim == null) {
credits.reserve( credits.reserve(
accountId = subject.accountId, accountId = subject.accountId,
estimate = estimate, estimate = estimate,
@@ -85,38 +118,45 @@ class GatewayService(
null null
} }
try { try {
usageRecords.claim( if (!subject.isOobe) {
ProviderRequestMetadata( usageRecords.claim(
requestId = request.requestId, ProviderRequestMetadata(
accountId = subject.accountId, requestId = request.requestId,
reservationId = reservation?.id, accountId = subject.accountId,
providerId = provider.descriptor.id, reservationId = reservation?.id,
capability = request.capability, providerId = provider.descriptor.id,
requestSource = request.requestSource, capability = request.capability,
requestPurpose = request.requestPurpose, requestSource = request.requestSource,
), requestPurpose = request.requestPurpose,
) ),
)
}
} catch (replay: GatewayRequestAlreadyClaimedException) { } catch (replay: GatewayRequestAlreadyClaimedException) {
// The existing claim owns the reservation. Releasing it here would // The existing claim owns the reservation. Releasing it here would
// refund an in-flight or completed paid request. Complimentary // refund an in-flight or completed paid request. Complimentary
// claims are newly acquired above and must not remain stranded. // claims are newly acquired above and must not remain stranded.
if (complimentaryClaim != null) { if (complimentaryClaim != null) {
releaseAfterFailure(null, complimentaryClaim, replay) releaseAfterFailure(null, complimentaryClaim, oobeClaim, replay)
} }
throw replay throw replay
} catch (failure: Throwable) { } catch (failure: Throwable) {
releaseAfterFailure(reservation, complimentaryClaim, failure) releaseAfterFailure(reservation, complimentaryClaim, oobeClaim, failure)
throw failure throw failure
} }
try { try {
usageRecords.markStarted(subject.accountId, request.requestId) if (oobeClaim != null) {
requireNotNull(oobeRequests).markStarted(oobeClaim)
} else {
usageRecords.markStarted(subject.accountId, request.requestId)
}
} catch (failure: Throwable) { } catch (failure: Throwable) {
releaseAndRecord( releaseAndRecord(
subject.accountId, subject.accountId,
request.requestId, request.requestId,
reservation, reservation,
complimentaryClaim, complimentaryClaim,
oobeClaim,
failure, failure,
) )
throw failure throw failure
@@ -129,6 +169,7 @@ class GatewayService(
estimate, estimate,
reservation, reservation,
complimentaryClaim, complimentaryClaim,
oobeClaim,
) )
} }
@@ -172,6 +213,10 @@ class GatewayService(
// Once upstream has completed, cancellation must not interrupt durable // Once upstream has completed, cancellation must not interrupt durable
// metering. The reservation remains frozen if any settlement step fails. // metering. The reservation remains frozen if any settlement step fails.
withContext(NonCancellable) { withContext(NonCancellable) {
if (prepared.oobeClaim != null) {
settleOobe(prepared, usage)
return@withContext
}
if (prepared.complimentaryClaim != null) { if (prepared.complimentaryClaim != null) {
settleComplimentary(prepared, usage) settleComplimentary(prepared, usage)
return@withContext return@withContext
@@ -214,6 +259,7 @@ class GatewayService(
prepared.request.requestId, prepared.request.requestId,
prepared.reservation, prepared.reservation,
prepared.complimentaryClaim, prepared.complimentaryClaim,
prepared.oobeClaim,
failure, failure,
) )
} }
@@ -224,11 +270,15 @@ class GatewayService(
failure: Throwable, failure: Throwable,
) { ) {
runCatching { runCatching {
usageRecords.markManualReview( if (prepared.oobeClaim != null) {
prepared.subject.accountId, requireNotNull(oobeRequests).markManualReview(prepared.oobeClaim, errorCode)
prepared.request.requestId, } else {
errorCode, usageRecords.markManualReview(
) prepared.subject.accountId,
prepared.request.requestId,
errorCode,
)
}
}.onFailure(failure::addSuppressed) }.onFailure(failure::addSuppressed)
} }
@@ -237,14 +287,20 @@ class GatewayService(
requestId: String, requestId: String,
reservation: CreditReservation?, reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?, complimentaryClaim: ComplimentaryRequestClaim?,
oobeClaim: OobeRequestClaim?,
failure: Throwable, failure: Throwable,
): Unit = withContext(NonCancellable) { ): Unit = withContext(NonCancellable) {
val released = if (complimentaryClaim != null) { val released = when {
runCatching { complimentaryRequests.release(complimentaryClaim) } oobeClaim != null -> runCatching {
} else { requireNotNull(oobeRequests).release(
runCatching { credits.release(requireNotNull(reservation).id) } oobeClaim,
failure::class.simpleName ?: "provider_error",
)
}
complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) }
else -> runCatching { credits.release(requireNotNull(reservation).id) }
} }
if (released.isSuccess) { if (released.isSuccess && oobeClaim == null) {
runCatching { runCatching {
usageRecords.markReleased( usageRecords.markReleased(
accountId, accountId,
@@ -252,11 +308,17 @@ class GatewayService(
failure::class.simpleName ?: "provider_error", failure::class.simpleName ?: "provider_error",
) )
}.onFailure(failure::addSuppressed) }.onFailure(failure::addSuppressed)
} else { } else if (released.isFailure) {
released.exceptionOrNull()?.let(failure::addSuppressed) released.exceptionOrNull()?.let(failure::addSuppressed)
runCatching { if (oobeClaim != null) {
usageRecords.markManualReview(accountId, requestId, "release_pending") runCatching {
}.onFailure(failure::addSuppressed) requireNotNull(oobeRequests).markManualReview(oobeClaim, "release_pending")
}.onFailure(failure::addSuppressed)
} else {
runCatching {
usageRecords.markManualReview(accountId, requestId, "release_pending")
}.onFailure(failure::addSuppressed)
}
} }
} }
@@ -284,6 +346,19 @@ class GatewayService(
} }
} }
private suspend fun settleOobe(
prepared: PreparedGatewayRequest,
usage: ProviderUsage,
) {
val claim = requireNotNull(prepared.oobeClaim)
runCatching { requireNotNull(oobeRequests).consume(claim, usage) }
.onFailure {
runCatching {
requireNotNull(oobeRequests).markManualReview(claim, "oobe_consume_pending")
}
}
}
private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) { private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) {
if (usage.meter != estimate.meter) { if (usage.meter != estimate.meter) {
throw GatewayUsagePolicyException("Provider usage meter differs from the reservation") throw GatewayUsagePolicyException("Provider usage meter differs from the reservation")
@@ -352,15 +427,33 @@ class GatewayService(
} }
} }
private fun validateAnonymousOobeRequest(request: ProviderRequest) {
require(request is TextProviderRequest) { "OOBE tokens support only LLM requests" }
require(request.requestPurpose == GatewayRequestPurpose.OOBE) {
"OOBE tokens require requestPurpose=oobe"
}
val feature = requireNotNull(request.oobeFeature) { "OOBE tokens require oobeFeature" }
val policy = OobeContract.policy(feature)
require(request.capability == policy.capability && request.executionPolicy.taskKind == policy.taskKind) {
"oobeFeature does not match capability and taskKind"
}
}
private suspend fun releaseAfterFailure( private suspend fun releaseAfterFailure(
reservation: CreditReservation?, reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?, complimentaryClaim: ComplimentaryRequestClaim?,
oobeClaim: OobeRequestClaim?,
failure: Throwable, failure: Throwable,
): Unit = withContext(NonCancellable) { ): Unit = withContext(NonCancellable) {
val released = if (complimentaryClaim != null) { val released = when {
runCatching { complimentaryRequests.release(complimentaryClaim) } oobeClaim != null -> runCatching {
} else { requireNotNull(oobeRequests).release(
runCatching { credits.release(requireNotNull(reservation).id) } oobeClaim,
failure::class.simpleName ?: "provider_error",
)
}
complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) }
else -> runCatching { credits.release(requireNotNull(reservation).id) }
} }
released released
.onFailure(failure::addSuppressed) .onFailure(failure::addSuppressed)
@@ -368,6 +461,7 @@ class GatewayService(
private companion object { private companion object {
val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}") val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}")
val OOBE_CLAIM_TTL: java.time.Duration = java.time.Duration.ofMinutes(15)
} }
} }
@@ -378,6 +472,7 @@ data class PreparedGatewayRequest(
val estimate: ProviderUsageEstimate, val estimate: ProviderUsageEstimate,
val reservation: CreditReservation?, val reservation: CreditReservation?,
val complimentaryClaim: ComplimentaryRequestClaim?, val complimentaryClaim: ComplimentaryRequestClaim?,
val oobeClaim: OobeRequestClaim?,
) )
class GatewayReconciliationService( class GatewayReconciliationService(
@@ -8,22 +8,35 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.route import io.ktor.server.routing.route
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
fun Route.healthRoutes(databaseFactory: DatabaseFactory) { fun Route.healthRoutes(
databaseFactory: DatabaseFactory,
buildSha: String = System.getenv("APP_BUILD_SHA")
?.takeIf(BUILD_SHA::matches)
?: "unknown",
) {
route("/health") { route("/health") {
get("/live") { get("/live") {
call.respond(HealthResponse(status = "UP")) call.respond(HealthResponse(status = "UP", buildSha = buildSha))
} }
get("/ready") { get("/ready") {
val databaseReady = databaseFactory.isReady() val databaseReady = databaseFactory.isReady()
if (databaseReady) { if (databaseReady) {
call.respond(HealthResponse(status = "UP")) call.respond(HealthResponse(status = "UP", buildSha = buildSha))
} else { } else {
call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN")) call.respond(
HttpStatusCode.ServiceUnavailable,
HealthResponse(status = "DOWN", buildSha = buildSha),
)
} }
} }
} }
} }
@Serializable @Serializable
private data class HealthResponse(val status: String) private data class HealthResponse(
val status: String,
val buildSha: String,
)
private val BUILD_SHA = Regex("[0-9a-f]{40}")
@@ -170,9 +170,18 @@ class LibraryAppAttestCrypto(
private val rpIdHash = sha256( private val rpIdHash = sha256(
"${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8), "${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8),
) )
private val expectedAaguid = when (config.appleEnvironment) { private val allowedAaguids = buildList {
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID add(
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID when (config.appleEnvironment) {
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID
},
)
if (config.allowDevelopmentAppAttest &&
config.appleEnvironment == AppleServiceEnvironment.PRODUCTION
) {
add(DEVELOPMENT_AAGUID)
}
} }
override suspend fun validateAttestation( override suspend fun validateAttestation(
@@ -197,7 +206,7 @@ class LibraryAppAttestCrypto(
if (authenticatorData.signCount != 0L) { if (authenticatorData.signCount != 0L) {
throw AppAttestRejectedException("App Attest attestation counter must start at zero") throw AppAttestRejectedException("App Attest attestation counter must start at zero")
} }
if (!MessageDigest.isEqual(authenticatorData.aaguid, expectedAaguid)) { if (allowedAaguids.none { MessageDigest.isEqual(authenticatorData.aaguid, it) }) {
throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment") throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment")
} }
val decodedKeyId = decodeKeyId(keyId) val decodedKeyId = decodeKeyId(keyId)
@@ -0,0 +1,388 @@
package com.osglab.account.features.oobe
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.ProviderUsage
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.time.Clock
private object OobeSubjectsTable : Table("oobe_subjects") {
val id = varchar("id", 36)
val keyId = varchar("key_id", 128)
val installationHash = char("installation_hash", 64)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object OobeGrantsTable : Table("oobe_gateway_grants") {
val id = varchar("id", 36)
val subjectId = varchar("subject_id", 36)
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object OobeRefreshTokensTable : Table("oobe_gateway_refresh_tokens") {
val id = varchar("id", 36)
val grantId = varchar("grant_id", 36)
val familyId = varchar("family_id", 36)
val tokenHash = char("token_hash", 64)
val replacedById = varchar("replaced_by_id", 36).nullable()
val rotationIdempotencyKey = varchar("rotation_idempotency_key", 128).nullable()
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object OobeClaimsTable : Table("oobe_gateway_claims") {
val subjectId = varchar("subject_id", 36)
val feature = varchar("feature", 32)
val requestId = varchar("request_id", 64)
val status = varchar("status", 16)
val expiresAt = timestamp("expires_at")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(subjectId, feature)
}
private object OobeProviderRequestsTable : Table("oobe_provider_requests") {
val subjectId = varchar("subject_id", 36)
val requestId = varchar("request_id", 64)
val grantId = varchar("grant_id", 36)
val feature = varchar("feature", 32)
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val requestPurpose = varchar("request_purpose", 32)
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
val usageUnits = long("usage_units").nullable()
val usageInputUnits = long("usage_input_units").nullable()
val usageOutputUnits = long("usage_output_units").nullable()
val serverDurationMillis = long("server_duration_millis").nullable()
val errorCode = varchar("error_code", 96).nullable()
val createdAt = timestamp("created_at")
val completedAt = timestamp("completed_at").nullable()
override val primaryKey = PrimaryKey(subjectId, requestId)
}
class ExposedOobeRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : OobeRepository {
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = databaseFactory.query {
OobeSubjectsTable.insertIgnore {
it[id] = subjectId
it[OobeSubjectsTable.keyId] = keyId
it[OobeSubjectsTable.installationHash] = installationHash
it[createdAt] = now
it[updatedAt] = now
}
OobeSubjectsTable.selectAll()
.where { OobeSubjectsTable.keyId eq keyId }
.single()
.also {
require(it[OobeSubjectsTable.installationHash] == installationHash) {
"App Attest key is already bound to another installation"
}
}
.let {
OobeSubject(
id = it[OobeSubjectsTable.id],
keyId = it[OobeSubjectsTable.keyId],
installationHash = it[OobeSubjectsTable.installationHash],
)
}
}
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh =
databaseFactory.query {
OobeGrantsTable.insert {
it[id] = grant.grant.id
it[subjectId] = grant.grant.subjectId
it[expiresAt] = grant.grant.expiresAt
it[createdAt] = now
it[updatedAt] = now
}
OobeRefreshTokensTable.insert {
it[id] = grant.refreshTokenId
it[grantId] = grant.grant.id
it[familyId] = grant.refreshFamilyId
it[tokenHash] = grant.refreshTokenHash
it[expiresAt] = minOf(grant.refreshExpiresAt, grant.grant.expiresAt)
it[createdAt] = now
}
StoredOobeRefresh(
grant = grant.grant,
tokenId = grant.refreshTokenId,
familyId = grant.refreshFamilyId,
expiresAt = minOf(grant.refreshExpiresAt, grant.grant.expiresAt),
)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult = databaseFactory.query {
val current = OobeRefreshTokensTable.selectAll()
.where { OobeRefreshTokensTable.tokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query OobeRefreshRotationResult.Invalid
val grant = OobeGrantsTable.selectAll()
.where { OobeGrantsTable.id eq current[OobeRefreshTokensTable.grantId] }
.forUpdate()
.single()
current[OobeRefreshTokensTable.replacedById]?.let { replacementId ->
if (current[OobeRefreshTokensTable.rotationIdempotencyKey] == rotationIdempotencyKey) {
val replacement = OobeRefreshTokensTable.selectAll()
.where { OobeRefreshTokensTable.id eq replacementId }
.single()
return@query OobeRefreshRotationResult.Rotated(
replacement.toStoredRefresh(grant.toOobeGrant()),
)
}
OobeRefreshTokensTable.update({
OobeRefreshTokensTable.familyId eq current[OobeRefreshTokensTable.familyId]
}) {
it[revokedAt] = now
}
OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) {
it[reuseDetectedAt] = now
}
OobeGrantsTable.update({ OobeGrantsTable.id eq grant[OobeGrantsTable.id] }) {
it[revokedAt] = now
it[updatedAt] = now
}
return@query OobeRefreshRotationResult.ReuseDetected
}
if (current[OobeRefreshTokensTable.revokedAt] != null ||
!current[OobeRefreshTokensTable.expiresAt].isAfter(now) ||
grant[OobeGrantsTable.revokedAt] != null ||
!grant[OobeGrantsTable.expiresAt].isAfter(now)
) {
return@query OobeRefreshRotationResult.Invalid
}
val expiresAt = minOf(newExpiresAt, grant[OobeGrantsTable.expiresAt])
OobeRefreshTokensTable.insert {
it[id] = newTokenId
it[grantId] = current[OobeRefreshTokensTable.grantId]
it[familyId] = current[OobeRefreshTokensTable.familyId]
it[tokenHash] = newTokenHash
it[OobeRefreshTokensTable.expiresAt] = expiresAt
it[createdAt] = now
}
OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) {
it[replacedById] = newTokenId
it[OobeRefreshTokensTable.rotationIdempotencyKey] = rotationIdempotencyKey
it[revokedAt] = now
}
OobeRefreshRotationResult.Rotated(
StoredOobeRefresh(
grant = grant.toOobeGrant(),
tokenId = newTokenId,
familyId = current[OobeRefreshTokensTable.familyId],
expiresAt = expiresAt,
),
)
}
override suspend fun findActiveGrant(
grantId: String,
subjectId: String,
now: Instant,
): OobeGrant? = databaseFactory.query {
OobeGrantsTable.selectAll()
.where {
(OobeGrantsTable.id eq grantId) and
(OobeGrantsTable.subjectId eq subjectId) and
OobeGrantsTable.revokedAt.isNull() and
(OobeGrantsTable.expiresAt greater now)
}
.singleOrNull()
?.toOobeGrant()
}
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? = databaseFactory.query {
val key = claimKey(request.subjectId, request.feature.name)
val reclaimed = OobeClaimsTable.update({
key and
(OobeClaimsTable.status eq CLAIMED) and
(OobeClaimsTable.expiresAt lessEq now)
}) {
it[requestId] = request.requestId
it[OobeClaimsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
val inserted = !reclaimed && OobeClaimsTable.insertIgnore {
it[subjectId] = request.subjectId
it[feature] = request.feature.name
it[requestId] = request.requestId
it[status] = CLAIMED
it[OobeClaimsTable.expiresAt] = expiresAt
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
if (!reclaimed && !inserted) return@query null
val auditInserted = OobeProviderRequestsTable.insertIgnore {
it[subjectId] = request.subjectId
it[requestId] = request.requestId
it[grantId] = request.grantId
it[feature] = request.feature.name
it[providerId] = request.providerId
it[capability] = request.capability.name
it[requestPurpose] = request.purpose.name
it[status] = OobeProviderRequestState.CLAIMED.name
it[createdAt] = now
}.insertedCount == 1
if (!auditInserted) throw OobeRequestAlreadyClaimedException()
OobeRequestClaim(request.subjectId, request.feature, request.requestId)
}
override suspend fun markStarted(claim: OobeRequestClaim) {
transition(claim, OobeProviderRequestState.CLAIMED, OobeProviderRequestState.STARTED)
}
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
databaseFactory.query {
val now = clock.instant()
val claimChanged = OobeClaimsTable.update({
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}) {
it[status] = CONSUMED
it[updatedAt] = now
}
check(claimChanged == 1) { "OOBE feature claim cannot be consumed" }
val auditChanged = OobeProviderRequestsTable.update({
requestKey(claim) and
(OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name)
}) {
it[status] = OobeProviderRequestState.SUCCEEDED.name
it[providerRequestId] = usage.providerRequestId
it[usageMeter] = usage.meter.name
it[usageUnits] = usage.units
it[usageInputUnits] = usage.inputUnits
it[usageOutputUnits] = usage.outputUnits
it[serverDurationMillis] = usage.serverDurationMillis
it[completedAt] = now
}
check(auditChanged == 1) { "OOBE provider request cannot be completed" }
}
}
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
databaseFactory.query {
OobeClaimsTable.deleteWhere {
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}
val changed = OobeProviderRequestsTable.update({
requestKey(claim) and
(
(OobeProviderRequestsTable.status eq OobeProviderRequestState.CLAIMED.name) or
(OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name)
)
}) {
it[status] = OobeProviderRequestState.RELEASED.name
it[OobeProviderRequestsTable.errorCode] = errorCode.take(96)
it[completedAt] = clock.instant()
}
check(changed == 1) { "OOBE provider request cannot be released" }
}
}
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) {
databaseFactory.query {
// Fail closed: an uncertain provider outcome must never become
// reclaimable after the temporary claim TTL.
OobeClaimsTable.update({
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}) {
it[status] = CONSUMED
it[updatedAt] = clock.instant()
}
OobeProviderRequestsTable.update({ requestKey(claim) }) {
it[status] = OobeProviderRequestState.MANUAL_REVIEW.name
it[OobeProviderRequestsTable.errorCode] = errorCode.take(96)
}
}
}
private suspend fun transition(
claim: OobeRequestClaim,
from: OobeProviderRequestState,
to: OobeProviderRequestState,
) {
databaseFactory.query {
val changed = OobeProviderRequestsTable.update({
requestKey(claim) and (OobeProviderRequestsTable.status eq from.name)
}) {
it[status] = to.name
}
check(changed == 1) { "OOBE provider request cannot transition from $from to $to" }
}
}
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toOobeGrant() = OobeGrant(
id = this[OobeGrantsTable.id],
subjectId = this[OobeGrantsTable.subjectId],
expiresAt = this[OobeGrantsTable.expiresAt],
revokedAt = this[OobeGrantsTable.revokedAt],
)
private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeGrant) =
StoredOobeRefresh(
grant = grant,
tokenId = this[OobeRefreshTokensTable.id],
familyId = this[OobeRefreshTokensTable.familyId],
expiresAt = this[OobeRefreshTokensTable.expiresAt],
)
private fun claimKey(subjectId: String, feature: String) =
(OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature)
private fun requestKey(claim: OobeRequestClaim) =
(OobeProviderRequestsTable.subjectId eq claim.subjectId) and
(OobeProviderRequestsTable.requestId eq claim.requestId)
private const val CLAIMED = "CLAIMED"
private const val CONSUMED = "CONSUMED"
@@ -0,0 +1,240 @@
package com.osglab.account.features.oobe
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.integrity.AppAttestService
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.util.Base64
import java.util.Date
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class OobeGrantService(
private val repository: OobeRepository,
private val appAttest: AppAttestService,
private val settings: OobeTokenSettings,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun create(request: CreateOobeGrantRequest): OobeGrantTokens {
val installationId = canonicalInstallationId(request.installationId)
val challenge = decodeChallenge(request.challenge)
val canonicalPayload = OobeContract.canonicalAssertionPayload(
challenge = challenge,
keyId = request.keyId,
installationId = installationId,
)
appAttest.verifyBoundAssertion(
challengeId = request.challengeId,
challenge = challenge,
keyId = request.keyId,
assertionObject = request.assertion,
expectedClientDataHash = sha256(canonicalPayload),
)
val now = clock.instant()
val subject = repository.findOrCreateSubject(
keyId = request.keyId,
installationHash = sha256Hex(installationId.toByteArray(StandardCharsets.UTF_8)),
subjectId = UUID.randomUUID().toString(),
now = now,
)
val grantId = UUID.randomUUID().toString()
val tokenId = UUID.randomUUID().toString()
val familyId = UUID.randomUUID().toString()
val grantExpiresAt = now.plus(GRANT_LIFETIME)
val refreshToken = refreshToken(grantId, familyId, tokenId)
val stored = repository.createGrant(
NewOobeGrant(
grant = OobeGrant(grantId, subject.id, grantExpiresAt),
refreshTokenId = tokenId,
refreshFamilyId = familyId,
refreshTokenHash = tokenHash(refreshToken),
refreshExpiresAt = grantExpiresAt,
),
now,
)
return issue(stored)
}
suspend fun refresh(refreshToken: String, idempotencyKey: String): OobeGrantTokens {
require(IDEMPOTENCY_KEY.matches(idempotencyKey)) { "Idempotency key is invalid" }
if (refreshToken.length !in 32..MAX_REFRESH_TOKEN_CHARS) {
throw OobeRefreshTokenInvalidException()
}
parseRefreshToken(refreshToken)
val now = clock.instant()
val tokenId = UUID.randomUUID().toString()
val result = repository.rotateRefresh(
currentTokenHash = tokenHash(refreshToken),
rotationIdempotencyKey = idempotencyKey,
newTokenId = tokenId,
newTokenHash = tokenHash(replaceTokenId(refreshToken, tokenId)),
newExpiresAt = now.plus(GRANT_LIFETIME),
now = now,
)
return when (result) {
is OobeRefreshRotationResult.Rotated -> issue(result.refresh)
OobeRefreshRotationResult.Invalid -> throw OobeRefreshTokenInvalidException()
OobeRefreshRotationResult.ReuseDetected -> throw OobeRefreshTokenReuseException()
}
}
suspend fun authenticate(serialized: String): GatewayPrincipal? {
val principal = verifyAccessToken(serialized) ?: return null
return repository.findActiveGrant(
grantId = requireNotNull(principal.grantId),
subjectId = principal.userId,
now = clock.instant(),
)?.let {
principal
}
}
private fun issue(refresh: StoredOobeRefresh): OobeGrantTokens {
val now = clock.instant()
val accessExpiresAt = minOf(now.plus(ACCESS_LIFETIME), refresh.grant.expiresAt)
require(accessExpiresAt.isAfter(now)) { "OOBE gateway grant has expired" }
require(refresh.expiresAt.isAfter(now)) { "OOBE refresh token has expired" }
val claims = JWTClaimsSet.Builder()
.issuer(settings.issuer)
.audience(settings.audience)
.subject("$SUBJECT_PREFIX${refresh.grant.subjectId}")
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(accessExpiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_GRANT_ID, refresh.grant.id)
.claim(CLAIM_SCOPES, OobeContract.scopes.map { it.name.lowercase() }.sorted())
.claim(CLAIM_FEATURES, OobeContract.features.map { it.name.lowercase() }.sorted())
.build()
val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
jwt.sign(MACSigner(settings.accessTokenHmacSecret))
return OobeGrantTokens(
grantId = refresh.grant.id,
scopes = OobeContract.scopes,
features = OobeContract.features,
accessToken = jwt.serialize(),
accessExpiresAt = accessExpiresAt.toString(),
refreshToken = refreshToken(refresh.grant.id, refresh.familyId, refresh.tokenId),
refreshExpiresAt = refresh.expiresAt.toString(),
)
}
private fun verifyAccessToken(serialized: String): GatewayPrincipal? = runCatching {
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.verify(MACVerifier(settings.accessTokenHmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == settings.issuer)
require(settings.audience in claims.audience)
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
require(claims.expirationTime?.toInstant()?.isAfter(now) == true)
require(claims.notBeforeTime?.toInstant()?.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)) != false)
require(claims.issueTime?.toInstant()?.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) != true)
require(claims.getStringListClaim(CLAIM_SCOPES).map(String::uppercase)
.map(GatewayCapability::valueOf).toSet() == OobeContract.scopes)
require(claims.getStringListClaim(CLAIM_FEATURES).map(String::uppercase).toSet() ==
OobeContract.features.map { it.name }.toSet())
val subject = claims.subject
require(subject.startsWith(SUBJECT_PREFIX))
val subjectId = UUID.fromString(subject.removePrefix(SUBJECT_PREFIX)).toString()
GatewayPrincipal(
userId = subjectId,
grantId = UUID.fromString(claims.getStringClaim(CLAIM_GRANT_ID)).toString(),
scopes = OobeContract.scopes,
subjectType = GatewaySubjectType.OOBE,
)
}.getOrNull()
private fun refreshToken(grantId: String, familyId: String, tokenId: String): String {
val publicPart = "$grantId.$familyId.$tokenId"
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(SecretKeySpec(settings.refreshTokenHmacSecret, HMAC_ALGORITHM))
val secret = Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal("$REFRESH_CONTEXT:$publicPart".toByteArray(StandardCharsets.US_ASCII)))
return "$REFRESH_PREFIX$publicPart.$secret"
}
private fun parseRefreshToken(value: String) {
if (!value.startsWith(REFRESH_PREFIX)) throw OobeRefreshTokenInvalidException()
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
if (parts.size != 4) throw OobeRefreshTokenInvalidException()
val grantId = canonicalUuid(parts[0])
val familyId = canonicalUuid(parts[1])
val tokenId = canonicalUuid(parts[2])
val expected = refreshToken(grantId, familyId, tokenId)
if (!MessageDigest.isEqual(
expected.toByteArray(StandardCharsets.US_ASCII),
value.toByteArray(StandardCharsets.US_ASCII),
)
) {
throw OobeRefreshTokenInvalidException()
}
}
private fun replaceTokenId(value: String, newTokenId: String): String {
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
return refreshToken(parts[0], parts[1], newTokenId)
}
private fun canonicalInstallationId(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw IllegalArgumentException("installationId must be a UUID") }
private fun canonicalUuid(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw OobeRefreshTokenInvalidException() }
private fun decodeChallenge(value: String): ByteArray =
runCatching { Base64.getUrlDecoder().decode(value) }
.getOrElse { throw IllegalArgumentException("challenge must be Base64URL") }
.also { require(it.size == CHALLENGE_BYTES) { "challenge size is invalid" } }
private fun tokenHash(value: String): String = sha256Hex(value.toByteArray(StandardCharsets.US_ASCII))
private companion object {
val GRANT_LIFETIME: Duration = Duration.ofMinutes(30)
val ACCESS_LIFETIME: Duration = Duration.ofMinutes(5)
const val HMAC_ALGORITHM = "HmacSHA256"
const val REFRESH_CONTEXT = "oobe-refresh"
const val CLAIM_TYPE = "typ"
const val CLAIM_GRANT_ID = "gid"
const val CLAIM_SCOPES = "scp"
const val CLAIM_FEATURES = "features"
const val ACCESS_TOKEN_TYPE = "oobe_gateway_access"
const val SUBJECT_PREFIX = "oobe:"
const val REFRESH_PREFIX = "oobert_"
const val CLOCK_SKEW_SECONDS = 30L
const val CHALLENGE_BYTES = 32
const val MAX_REFRESH_TOKEN_CHARS = 512
val IDEMPOTENCY_KEY = Regex("[A-Za-z0-9._:-]{8,128}")
}
}
class OobeRefreshTokenInvalidException : RuntimeException("OOBE refresh token is invalid")
class OobeRefreshTokenReuseException : RuntimeException("OOBE refresh token reuse was detected")
data class OobeTokenSettings(
val issuer: String,
val audience: String,
val accessTokenHmacSecret: ByteArray,
val refreshTokenHmacSecret: ByteArray,
)
private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
private fun sha256Hex(value: ByteArray): String =
sha256(value).joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -0,0 +1,128 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.OobeFeature
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.Base64
@Serializable
data class CreateOobeGrantRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val installationId: String,
val assertion: String,
)
@Serializable
data class RefreshOobeGrantRequest(val refreshToken: String)
@Serializable
data class OobeGrantTokens(
val grantId: String,
val scopes: Set<GatewayCapability>,
val features: Set<OobeFeature>,
val accessToken: String,
val accessExpiresAt: String,
val refreshToken: String,
val refreshExpiresAt: String,
)
data class OobeFeaturePolicy(
val capability: GatewayCapability,
val taskKind: GatewayTaskKind,
)
object OobeContract {
val scopes: Set<GatewayCapability> = setOf(GatewayCapability.POLISH, GatewayCapability.AI)
val features: Set<OobeFeature> = OobeFeature.entries.toSet()
fun policy(feature: OobeFeature): OobeFeaturePolicy = when (feature) {
OobeFeature.VOICE_INPUT ->
OobeFeaturePolicy(GatewayCapability.POLISH, GatewayTaskKind.DICTATION_POLISH)
OobeFeature.CLIPBOARD_TRANSLATE,
OobeFeature.CLIPBOARD_REPLY ->
OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.CLIPBOARD_TRANSFORM)
OobeFeature.ASK_AI ->
OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.AI_QUESTION)
}
fun canonicalAssertionPayload(
challenge: ByteArray,
keyId: String,
installationId: String,
): ByteArray = buildString {
appendLine("osg-app-attest-v1")
appendLine("purpose=oobe-gateway-grant")
appendLine("challenge=${BASE64_URL.encodeToString(challenge)}")
appendLine("key_id=$keyId")
appendLine("installation_id=$installationId")
appendLine("scopes=ai,polish")
appendLine("features=ask_ai,clipboard_reply,clipboard_translate,voice_input")
appendLine("grant_ttl_seconds=1800")
appendLine("access_ttl_seconds=300")
}.toByteArray(Charsets.UTF_8)
}
data class OobeSubject(
val id: String,
val keyId: String,
val installationHash: String,
)
data class OobeGrant(
val id: String,
val subjectId: String,
val expiresAt: Instant,
val revokedAt: Instant? = null,
)
data class NewOobeGrant(
val grant: OobeGrant,
val refreshTokenId: String,
val refreshFamilyId: String,
val refreshTokenHash: String,
val refreshExpiresAt: Instant,
)
data class StoredOobeRefresh(
val grant: OobeGrant,
val tokenId: String,
val familyId: String,
val expiresAt: Instant,
)
sealed interface OobeRefreshRotationResult {
data class Rotated(val refresh: StoredOobeRefresh) : OobeRefreshRotationResult
data object Invalid : OobeRefreshRotationResult
data object ReuseDetected : OobeRefreshRotationResult
}
data class OobeRequestClaim(
val subjectId: String,
val feature: OobeFeature,
val requestId: String,
)
data class OobeProviderRequest(
val subjectId: String,
val grantId: String,
val feature: OobeFeature,
val requestId: String,
val providerId: String,
val capability: GatewayCapability,
val purpose: GatewayRequestPurpose,
)
enum class OobeProviderRequestState {
CLAIMED,
STARTED,
SUCCEEDED,
RELEASED,
MANUAL_REVIEW,
}
private val BASE64_URL: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
@@ -0,0 +1,42 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.ProviderUsage
import java.time.Instant
interface OobeRepository {
suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject
suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh
suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult
suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant?
suspend fun claim(request: OobeProviderRequest, expiresAt: Instant, now: Instant): OobeRequestClaim?
suspend fun markStarted(claim: OobeRequestClaim)
suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage)
suspend fun release(claim: OobeRequestClaim, errorCode: String)
suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String)
}
class OobeRequestAlreadyClaimedException :
RuntimeException("The OOBE provider request ID has already been used")
class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) :
RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used")
@@ -0,0 +1,130 @@
package com.osglab.account.features.oobe
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.features.gateway.models.GatewayErrorResponse
import com.osglab.account.features.integrity.AppAttestRejectedException
import com.osglab.account.features.integrity.AppAttestUnavailableException
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlinx.io.readByteArray
import io.ktor.utils.io.readRemaining
fun Route.oobeRoutes(service: OobeGrantService) {
route("/v1/oobe/grants") {
post {
val requestId = call.requestId()
val request = runCatching {
OOBE_JSON.decodeFromString<CreateOobeGrantRequest>(call.receiveOobeBody())
}.getOrElse {
return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse("invalid_oobe_grant", "OOBE grant request is invalid", requestId),
)
}
try {
call.respond(HttpStatusCode.Created, service.create(request))
} catch (_: AppAttestRejectedException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("app_attest_rejected", "App Attest assertion was rejected", requestId),
)
} catch (_: AppAttestUnavailableException) {
call.respond(
HttpStatusCode.ServiceUnavailable,
GatewayErrorResponse(
"app_attest_unavailable",
"App Attest verification is unavailable",
requestId,
),
)
} catch (_: InvalidRequestException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_grant",
"OOBE grant request is invalid",
requestId,
),
)
} catch (failure: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_grant",
failure.message ?: "OOBE grant request is invalid",
requestId,
),
)
}
}
post("/refresh") {
val requestId = call.requestId()
val idempotencyKey = call.request.headers["Idempotency-Key"]
?: return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"missing_idempotency_key",
"Idempotency-Key is required",
requestId,
),
)
val request = runCatching {
OOBE_JSON.decodeFromString<RefreshOobeGrantRequest>(call.receiveOobeBody())
}.getOrElse {
return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh request is invalid", requestId),
)
}
try {
call.respond(service.refresh(request.refreshToken, idempotencyKey))
} catch (_: OobeRefreshTokenInvalidException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh token is invalid", requestId),
)
} catch (_: OobeRefreshTokenReuseException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("oobe_refresh_reuse", "OOBE refresh token reuse was detected", requestId),
)
} catch (failure: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_refresh",
failure.message ?: "OOBE refresh request is invalid",
requestId,
),
)
}
}
}
}
private fun io.ktor.server.application.ApplicationCall.requestId(): String =
request.headers["X-Request-ID"]?.takeIf { REQUEST_ID.matches(it) } ?: UUID.randomUUID().toString()
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private const val MAX_OOBE_BODY_BYTES = 128 * 1024
private val OOBE_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
private suspend fun io.ktor.server.application.ApplicationCall.receiveOobeBody(): String {
val declared = request.headers["Content-Length"]?.toLongOrNull()
require(declared == null || declared <= MAX_OOBE_BODY_BYTES)
val bytes = receiveChannel()
.readRemaining(MAX_OOBE_BODY_BYTES.toLong() + 1)
.readByteArray()
require(bytes.size <= MAX_OOBE_BODY_BYTES)
return bytes.decodeToString()
}
+1
View File
@@ -78,4 +78,5 @@ app:
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false" enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
enforceAppAttest: "$ENFORCE_APP_ATTEST:false" enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development" appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development"
allowDevelopmentAppAttest: "$ALLOW_DEVELOPMENT_APP_ATTEST:false"
challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300" challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300"
@@ -0,0 +1,87 @@
CREATE TABLE oobe_subjects (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_oobe_subject_key (key_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE oobe_gateway_grants (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
INDEX idx_oobe_grants_subject_expiry (subject_id, expires_at),
CONSTRAINT fk_oobe_grants_subject
FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE oobe_gateway_refresh_tokens (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
rotation_idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
reuse_detected_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_oobe_refresh_hash (token_hash),
INDEX idx_oobe_refresh_grant (grant_id),
INDEX idx_oobe_refresh_family (family_id),
CONSTRAINT fk_oobe_refresh_grant
FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE oobe_gateway_claims (
subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (subject_id, feature),
INDEX idx_oobe_claim_expiry (status, expires_at),
CONSTRAINT fk_oobe_claim_subject
FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE,
CONSTRAINT chk_oobe_claim_feature
CHECK (feature IN ('VOICE_INPUT', 'CLIPBOARD_TRANSLATE', 'CLIPBOARD_REPLY', 'ASK_AI')),
CONSTRAINT chk_oobe_claim_status
CHECK (status IN ('CLAIMED', 'CONSUMED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE oobe_provider_requests (
subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_purpose VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider_request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
usage_meter VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
usage_units BIGINT NULL,
usage_input_units BIGINT NULL,
usage_output_units BIGINT NULL,
server_duration_millis BIGINT NULL,
error_code VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NULL,
created_at TIMESTAMP(6) NOT NULL,
completed_at TIMESTAMP(6) NULL,
PRIMARY KEY (subject_id, request_id),
INDEX idx_oobe_provider_feature_created (feature, created_at),
INDEX idx_oobe_provider_status_created (status, created_at),
CONSTRAINT fk_oobe_provider_subject
FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE,
CONSTRAINT fk_oobe_provider_grant
FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE,
CONSTRAINT chk_oobe_provider_purpose CHECK (request_purpose = 'OOBE')
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -36,6 +36,15 @@ class AppConfigTest : FunSpec({
config.environment shouldBe Environment.PRODUCTION config.environment shouldBe Environment.PRODUCTION
config.database.username shouldBe "test" config.database.username shouldBe "test"
config.database.migrationUsername shouldBe "test_migrator" config.database.migrationUsername shouldBe "test_migrator"
config.integrity.allowDevelopmentAppAttest shouldBe false
}
test("production can explicitly allow development App Attest builds") {
val config = validProductionConfig().apply {
put("app.integrity.allowDevelopmentAppAttest", "true")
}
AppConfig.from(config).integrity.allowDevelopmentAppAttest shouldBe true
} }
test("production accepts enabled admin bootstrap with Argon2 PHC hash") { test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
@@ -378,6 +378,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/integrity/challenges", "/v1/integrity/challenges",
"/v1/integrity/attest", "/v1/integrity/attest",
"/v1/integrity/assert", "/v1/integrity/assert",
"/v1/oobe/grants",
"/v1/oobe/grants/refresh",
"/v1/gateway/catalog", "/v1/gateway/catalog",
"/v1/gateway/grants", "/v1/gateway/grants",
"/v1/gateway/grants/refresh", "/v1/gateway/grants/refresh",
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/" runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/" runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V17" runner shouldContain "Flyway history was not exactly successful V1-V26"
runner shouldContain "default referral rewards were not 1000 credits for both accounts" runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract" runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor" runner shouldContain "first ledger page omitted nextCursor"
@@ -72,6 +72,23 @@ class AppAttestCryptoTest : FunSpec({
} }
} }
test("production can explicitly allow development App Attest builds") {
val fixture = AppAttestFixture()
val developmentAaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII)
val crypto = fixture.crypto(
nonce = fixture.expectedNonce(developmentAaguid),
allowDevelopment = true,
)
val material = crypto.validateAttestation(
fixture.attestationObject(aaguid = developmentAaguid),
fixture.keyId,
fixture.challenge,
)
material.publicKey shouldBe fixture.keyPair.public.encoded
}
test("assertion verifies ECDSA and requires a strictly increasing counter") { test("assertion verifies ECDSA and requires a strictly increasing counter") {
val fixture = AppAttestFixture() val fixture = AppAttestFixture()
val hash = sha256ForTest("cost-request".toByteArray()) val hash = sha256ForTest("cost-request".toByteArray())
@@ -139,12 +156,16 @@ private class AppAttestFixture {
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)), sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
) )
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto = fun crypto(
nonce: ByteArray = expectedNonce(),
allowDevelopment: Boolean = false,
): LibraryAppAttestCrypto =
LibraryAppAttestCrypto( LibraryAppAttestCrypto(
IntegrityConfig( IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE, deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE, appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION, appleEnvironment = AppleServiceEnvironment.PRODUCTION,
allowDevelopmentAppAttest = allowDevelopment,
), ),
AppAttestCertificateValidator { AppAttestCertificateValidator {
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce) ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
@@ -189,8 +210,8 @@ private class AppAttestFixture {
.EncodeToBytes() .EncodeToBytes()
} }
private fun expectedNonce(): ByteArray = fun expectedNonce(aaguid: ByteArray = productionAaguid()): ByteArray =
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge)) sha256ForTest(attestationAuthData(rpIdHash, aaguid) + sha256ForTest(challenge))
private fun productionAaguid(): ByteArray = private fun productionAaguid(): ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7) "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
@@ -0,0 +1,264 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayModelProfile
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
import com.osglab.account.features.gateway.models.OobeFeature
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.GatewayService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.time.Instant
class OobeGatewayServiceTest : StringSpec({
"executes each fixed OOBE feature once without touching credits or account audit" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe)
OobeFeature.entries.forEachIndexed { index, feature ->
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD)
}
credits.calls shouldBe 0
oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet()
}
"rejects a fifth call and a repeated feature without paid fallback" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe)
OobeFeature.entries.forEachIndexed { index, feature ->
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD)
}
shouldThrow<OobeFeatureAlreadyUsedException> {
service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD)
}
credits.calls shouldBe 0
}
"releases the feature claim when the provider fails" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe, fail = true)
shouldThrow<ProviderFailure> {
service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD)
}
oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT)
credits.calls shouldBe 0
}
"enforces token boundary and exact feature mapping" {
val credits = CountingCredits()
val service = service(credits, FakeOobeExecutionRepository())
shouldThrow<IllegalArgumentException> {
service.execute(
OOBE_PRINCIPAL,
request(OobeFeature.ASK_AI, "oobe-wrong-map").copy(
executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM),
),
DISCARD,
)
}
shouldThrow<GatewayAccessDeniedException> {
service.execute(
ACCOUNT_PRINCIPAL,
request(OobeFeature.ASK_AI, "account-oobe-feature"),
DISCARD,
)
}
credits.calls shouldBe 0
}
})
private fun service(
credits: CountingCredits,
oobe: OobeRepository,
fail: Boolean = false,
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))),
credits = credits,
grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") },
usageRecords = NoAccountUsage,
oobeRequests = oobe,
)
private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "oobe-test-provider",
capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI),
streaming = false,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
if (fail) throw ProviderFailure()
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 2,
inputUnits = 1,
outputUnits = 1,
)
}
}
private class CountingCredits : CreditReservationPort {
var calls = 0
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
calls += 1
error("credits must not be called")
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
calls += 1
error("credits must not be called")
}
override suspend fun release(reservationId: String) {
calls += 1
error("credits must not be called")
}
}
private class FakeOobeExecutionRepository : OobeRepository {
private val claimedFeatures = mutableSetOf<OobeFeature>()
val consumed = mutableListOf<OobeRequestClaim>()
val released = mutableListOf<OobeRequestClaim>()
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? {
if (!claimedFeatures.add(request.feature)) return null
return OobeRequestClaim(request.subjectId, request.feature, request.requestId)
}
override suspend fun markStarted(claim: OobeRequestClaim) = Unit
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
consumed += claim
}
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
claimedFeatures -= claim.feature
released += claim
}
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = error("not used")
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used")
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult = error("not used")
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
error("not used")
}
private object NoAccountUsage : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called")
override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called")
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = error("account audit must not be called")
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) =
error("account audit must not be called")
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) =
error("account audit must not be called")
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) =
error("account audit must not be called")
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private fun request(feature: OobeFeature, requestId: String): TextProviderRequest {
val mapping = OobeContract.policy(feature)
return TextProviderRequest(
requestId = requestId,
capability = mapping.capability,
executionPolicy = policy(mapping.taskKind),
input = "hello",
context = null,
maxOutputTokens = 1,
temperature = 0.0,
stream = false,
requestPurpose = GatewayRequestPurpose.OOBE,
oobeFeature = feature,
)
}
private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.LOW_LATENCY,
thinking = GatewayThinkingMode.DISABLED,
reasoningEffort = null as GatewayReasoningEffort?,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = false,
maxOutputTokens = 1,
)
private val OOBE_PRINCIPAL = GatewayPrincipal(
userId = "20000000-0000-0000-0000-000000000001",
grantId = "30000000-0000-0000-0000-000000000001",
scopes = OobeContract.scopes,
subjectType = GatewaySubjectType.OOBE,
)
private val ACCOUNT_PRINCIPAL = GatewayPrincipal(
userId = "40000000-0000-0000-0000-000000000001",
scopes = OobeContract.scopes,
)
private val DISCARD = ProviderOutput {}
private class ProviderFailure : RuntimeException()
@@ -0,0 +1,299 @@
package com.osglab.account.features.oobe
import com.nimbusds.jwt.SignedJWT
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.integrity.AppAttestChallenge
import com.osglab.account.features.integrity.AppAttestChallengePurpose
import com.osglab.account.features.integrity.AppAttestCrypto
import com.osglab.account.features.integrity.AppAttestKeyStatus
import com.osglab.account.features.integrity.AppAttestRepository
import com.osglab.account.features.integrity.AppAttestService
import com.osglab.account.features.integrity.AppAttestRejectedException
import com.osglab.account.features.integrity.AttestedKeyMaterial
import com.osglab.account.features.integrity.ConsumedChallenge
import com.osglab.account.features.integrity.StoredAppAttestKey
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.util.Base64
import java.util.UUID
class OobeGrantServiceTest : StringSpec({
"canonical assertion is server-owned and binds all fixed permissions" {
val challenge = ByteArray(32) { it.toByte() }
val payload = OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID).decodeToString()
payload shouldBe """
osg-app-attest-v1
purpose=oobe-gateway-grant
challenge=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
key_id=$KEY_ID
installation_id=$INSTALLATION_ID
scopes=ai,polish
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
grant_ttl_seconds=1800
access_ttl_seconds=300
""".trimIndent()
}
"issues a distinct short-lived OOBE token after the canonical assertion" {
val clock = MutableClock(NOW)
val challenge = ByteArray(32) { 7 }
val expectedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val repository = FakeOobeRepository()
val service = service(repository, expectedHash, clock)
val tokens = service.create(request(challenge))
val jwt = SignedJWT.parse(tokens.accessToken).jwtClaimsSet
jwt.getStringClaim("typ") shouldBe "oobe_gateway_access"
jwt.subject.startsWith("oobe:") shouldBe true
jwt.getStringListClaim("scp").shouldContainExactlyInAnyOrder("polish", "ai")
jwt.getStringListClaim("features").shouldContainExactlyInAnyOrder(
"voice_input",
"clipboard_translate",
"clipboard_reply",
"ask_ai",
)
Duration.between(jwt.issueTime.toInstant(), jwt.expirationTime.toInstant()) shouldBe
Duration.ofMinutes(5)
Duration.between(NOW, Instant.parse(tokens.refreshExpiresAt)) shouldBe Duration.ofMinutes(30)
service.authenticate(tokens.accessToken)?.subjectType shouldBe GatewaySubjectType.OOBE
val overScoped = SignedJWT(
JWSHeader(JWSAlgorithm.HS256),
JWTClaimsSet.Builder(jwt)
.claim("scp", listOf("ai", "agent", "polish"))
.build(),
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
service.authenticate(overScoped) shouldBe null
val accountTyped = SignedJWT(
JWSHeader(JWSAlgorithm.HS256),
JWTClaimsSet.Builder(jwt)
.claim("typ", "gateway_access")
.build(),
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
service.authenticate(accountTyped) shouldBe null
clock.now = NOW.plus(Duration.ofMinutes(5))
service.authenticate(tokens.accessToken) shouldBe null
}
"rejects an assertion generated for a different installation payload" {
val challenge = ByteArray(32) { 9 }
val signedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val service = service(FakeOobeRepository(), signedHash, MutableClock(NOW))
shouldThrow<AppAttestRejectedException> {
service.create(request(challenge).copy(installationId = UUID.randomUUID().toString()))
}
}
"refresh cannot extend the original grant TTL" {
val clock = MutableClock(NOW)
val challenge = ByteArray(32) { 5 }
val expectedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val service = service(FakeOobeRepository(), expectedHash, clock)
val created = service.create(request(challenge))
clock.now = NOW.plus(Duration.ofMinutes(29))
val refreshed = service.refresh(created.refreshToken, "oobe-refresh-1")
refreshed.refreshExpiresAt shouldBe created.refreshExpiresAt
refreshed.accessExpiresAt shouldBe NOW.plus(Duration.ofMinutes(30)).toString()
}
})
private fun service(
repository: OobeRepository,
expectedHash: ByteArray,
clock: Clock,
): OobeGrantService {
val appAttestRepository = FakeAppAttestRepository()
val appAttest = AppAttestService(
repository = appAttestRepository,
crypto = HashCheckingAppAttestCrypto(expectedHash),
config = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
),
clock = clock,
)
return OobeGrantService(
repository = repository,
appAttest = appAttest,
settings = OobeTokenSettings(
issuer = "osg-test",
audience = "osg-gateway-test",
accessTokenHmacSecret = ByteArray(32) { 1 },
refreshTokenHmacSecret = ByteArray(32) { 2 },
),
clock = clock,
)
}
private class HashCheckingAppAttestCrypto(
private val expectedHash: ByteArray,
) : AppAttestCrypto {
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = error("not used")
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long {
if (!MessageDigest.isEqual(clientDataHash, expectedHash)) {
throw AppAttestRejectedException("canonical payload mismatch")
}
return lastCounter + 1
}
}
private class FakeAppAttestRepository : AppAttestRepository {
private var counter = 0L
override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge = ConsumedChallenge.Valid
override suspend fun saveKey(key: StoredAppAttestKey): Boolean = true
override suspend fun findKey(keyId: String): StoredAppAttestKey =
StoredAppAttestKey(
keyId = keyId,
publicKey = byteArrayOf(1),
receipt = byteArrayOf(1),
counter = counter,
accountId = null,
status = AppAttestKeyStatus.ACTIVE,
)
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
if (expectedCounter != counter || newCounter <= counter) return false
counter = newCounter
return true
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean = true
}
private class FakeOobeRepository : OobeRepository {
private val subjects = mutableMapOf<Pair<String, String>, OobeSubject>()
private val grants = mutableMapOf<String, OobeGrant>()
private val refreshes = mutableMapOf<String, StoredOobeRefresh>()
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = subjects.getOrPut(keyId to installationHash) {
OobeSubject(subjectId, keyId, installationHash)
}
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh {
grants[grant.grant.id] = grant.grant
return StoredOobeRefresh(
grant.grant,
grant.refreshTokenId,
grant.refreshFamilyId,
grant.refreshExpiresAt,
).also { refreshes[grant.refreshTokenHash] = it }
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult {
val current = refreshes[currentTokenHash] ?: return OobeRefreshRotationResult.Invalid
if (!current.expiresAt.isAfter(now) || !current.grant.expiresAt.isAfter(now)) {
return OobeRefreshRotationResult.Invalid
}
val replacement = StoredOobeRefresh(
grant = current.grant,
tokenId = newTokenId,
familyId = current.familyId,
expiresAt = minOf(newExpiresAt, current.grant.expiresAt),
)
refreshes[newTokenHash] = replacement
return OobeRefreshRotationResult.Rotated(replacement)
}
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
grants[grantId]?.takeIf { it.subjectId == subjectId && it.expiresAt.isAfter(now) }
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? = error("not used")
override suspend fun markStarted(claim: OobeRequestClaim) = error("not used")
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) = error("not used")
override suspend fun release(claim: OobeRequestClaim, errorCode: String) = error("not used")
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = error("not used")
}
private class MutableClock(var now: Instant) : Clock() {
override fun getZone(): ZoneId = ZoneId.of("UTC")
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = now
}
private fun request(challenge: ByteArray) = CreateOobeGrantRequest(
challengeId = UUID.randomUUID().toString(),
challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge),
keyId = KEY_ID,
installationId = INSTALLATION_ID,
assertion = Base64.getEncoder().encodeToString(byteArrayOf(1)),
)
private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
private val NOW = Instant.parse("2026-08-21T00:00:00Z")
private val INSTALLATION_ID = UUID.fromString("10000000-0000-0000-0000-000000000001").toString()
private val KEY_ID = Base64.getEncoder().encodeToString(ByteArray(32) { 3 })
@@ -0,0 +1,207 @@
package com.osglab.account.features.oobe
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.OobeFeature
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.DriverManager
import java.time.Duration
import java.time.Instant
import java.util.UUID
class OobeRepositoryIntegrationTest : FunSpec({
test("anonymous feature claim is atomic, consumed once, and independent from accounts") {
withOobeDatabase { config, databaseFactory ->
val repository = ExposedOobeRepository(databaseFactory)
val now = Instant.parse("2026-08-21T01:00:00Z")
val subject = repository.findOrCreateSubject(
keyId = "integration-key",
installationHash = "a".repeat(64),
subjectId = UUID.randomUUID().toString(),
now = now,
)
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plus(Duration.ofMinutes(30)))
repository.createGrant(
NewOobeGrant(
grant = grant,
refreshTokenId = UUID.randomUUID().toString(),
refreshFamilyId = UUID.randomUUID().toString(),
refreshTokenHash = "b".repeat(64),
refreshExpiresAt = grant.expiresAt,
),
now,
)
val claims = coroutineScope {
(1..12).map { index ->
async(Dispatchers.Default) {
repository.claim(
providerRequest(subject.id, grant.id, "concurrent-oobe-$index"),
now.plus(Duration.ofMinutes(15)),
now,
)
}
}.awaitAll()
}
val winningClaim = claims.filterNotNull().single()
repository.markStarted(winningClaim)
repository.consume(
winningClaim,
ProviderUsage(UsageMeter.LLM_TOKEN, 2, inputUnits = 1, outputUnits = 1),
)
repository.claim(
providerRequest(subject.id, grant.id, "repeat-after-success"),
now.plus(Duration.ofMinutes(15)),
now,
) shouldBe null
databaseCount(config, "accounts") shouldBe 0
databaseCount(config, "credit_ledger") shouldBe 0
databaseCount(config, "devicecheck_trial_claims") shouldBe 0
val accountId = UUID.randomUUID()
insertAccount(config, accountId, now)
val trial = CreditService(
transactions = ExposedBillingTransactionRunner(databaseFactory.database),
referralRewards = ReferralRewardConfig(
inviterCredits = 1_000,
inviteeCredits = 1_000,
),
).grantSignupTrial(
userId = accountId,
credits = 1_000,
idempotencyKey = signupTrialIdempotencyKey(accountId),
)
trial.balance shouldBe 1_000
}
}
test("provider failure releases the feature for a retry") {
withOobeDatabase { _, databaseFactory ->
val repository = ExposedOobeRepository(databaseFactory)
val now = Instant.parse("2026-08-21T02:00:00Z")
val subject = repository.findOrCreateSubject(
"release-key",
"c".repeat(64),
UUID.randomUUID().toString(),
now,
)
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plusSeconds(1_800))
repository.createGrant(
NewOobeGrant(
grant,
UUID.randomUUID().toString(),
UUID.randomUUID().toString(),
"d".repeat(64),
grant.expiresAt,
),
now,
)
val first = repository.claim(
providerRequest(subject.id, grant.id, "failure-first"),
now.plusSeconds(900),
now,
)
first shouldNotBe null
repository.markStarted(requireNotNull(first))
repository.release(first, "provider_failure")
repository.claim(
providerRequest(subject.id, grant.id, "failure-retry"),
now.plusSeconds(900),
now,
) shouldNotBe null
}
}
})
private fun providerRequest(subjectId: String, grantId: String, requestId: String) =
OobeProviderRequest(
subjectId = subjectId,
grantId = grantId,
feature = OobeFeature.ASK_AI,
requestId = requestId,
providerId = "integration-provider",
capability = GatewayCapability.AI,
purpose = GatewayRequestPurpose.OOBE,
)
private suspend fun withOobeDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
OobeMySqlContainer("mysql:8.4")
.withDatabaseName("osg_oobe_test")
.withUsername("test")
.withPassword("test")
.also(OobeMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 12,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun databaseCount(config: DatabaseConfig, table: String): Long =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.createStatement().use { statement ->
statement.executeQuery("SELECT COUNT(*) FROM $table").use { rows ->
rows.next()
rows.getLong(1)
}
}
}
private fun insertAccount(config: DatabaseConfig, accountId: UUID, now: Instant) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.setString(1, accountId.toString())
statement.setString(2, "oobe-signup-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.executeUpdate()
}
}
}
private class OobeMySqlContainer(image: String) : MySQLContainer<OobeMySqlContainer>(image)