Add anonymous OOBE gateway grants
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:
@@ -58,6 +58,8 @@ APPLE_TOKEN_URL=https://appleid.apple.com/auth/token
|
||||
APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke
|
||||
APPLE_INTEGRITY_ENVIRONMENT=development
|
||||
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.
|
||||
|
||||
# Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional.
|
||||
|
||||
@@ -89,6 +89,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
APP_BUILD_SHA=${{ github.sha }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
|
||||
./gradlew --no-daemon --no-configuration-cache --stacktrace installDist
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
ARG APP_BUILD_SHA=unknown
|
||||
RUN addgroup -S -g 10001 app \
|
||||
&& adduser -S -D -H -u 10001 -G app -h /app app
|
||||
WORKDIR /app
|
||||
@@ -17,6 +18,7 @@ WORKDIR /app
|
||||
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
|
||||
|
||||
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"
|
||||
|
||||
USER 10001:10001
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
|
||||
APPLE_INTEGRITY_ENVIRONMENT: production
|
||||
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_APP_ATTEST: "true"
|
||||
|
||||
|
||||
+11
-4
@@ -258,8 +258,15 @@ grant_pattern = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
expected = set()
|
||||
for raw_line in open(sys.argv[1], encoding="utf-8"):
|
||||
match = grant_pattern.match(raw_line.strip())
|
||||
with open(sys.argv[1], encoding="utf-8") as grants_file:
|
||||
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:
|
||||
for privilege in match.group(1).split(","):
|
||||
expected.add((match.group(2).lower(), privilege.strip().upper()))
|
||||
@@ -470,9 +477,9 @@ WHERE version IS NOT NULL
|
||||
ORDER BY installed_rank;
|
||||
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" ]] ||
|
||||
fail "Flyway history was not exactly successful V1-V17"
|
||||
fail "Flyway history was not exactly successful V1-V26"
|
||||
REFERRAL_REWARDS="$(
|
||||
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||||
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
|
||||
|
||||
@@ -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_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.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.app_attest_challenges 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, DELETE ON osg_account_smoke.gateway_complimentary_requests
|
||||
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.app_attest_challenges TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
|
||||
|
||||
@@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8
|
||||
v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
|
||||
三方凭据分别创建、分别轮换,不得复用。
|
||||
|
||||
生产默认仅接受 TestFlight 与 App Store 构建的 production App Attest。确需让已登记真机上的
|
||||
Xcode Development 构建连接生产服务时,可临时设置
|
||||
`ALLOW_DEVELOPMENT_APP_ATTEST=true`;完成测试后应立即恢复为 `false` 并重启服务。
|
||||
|
||||
## 5. 构建与启动
|
||||
|
||||
GitHub CI 在测试通过后发布私有镜像
|
||||
|
||||
@@ -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_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.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.app_attest_challenges 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, DELETE ON osg_account.gateway_complimentary_requests
|
||||
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.app_attest_challenges TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
|
||||
|
||||
+86
-5
@@ -381,6 +381,43 @@ paths:
|
||||
responses:
|
||||
"200": { description: Assertion counter advanced }
|
||||
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:
|
||||
get:
|
||||
summary: Return configured managed-provider capabilities
|
||||
@@ -441,9 +478,10 @@ paths:
|
||||
and output-budget policy from `capability` plus optional `taskKind`. It
|
||||
never infers task type from `input` or `context`, and clients cannot
|
||||
supply provider parameters. Search and tools are currently disabled.
|
||||
An authenticated `oobe` purpose is accepted only for dictation polish.
|
||||
The first successful request per account is complimentary; later attempts
|
||||
fail without falling through to paid billing.
|
||||
For account grants, `oobe` is accepted only for dictation polish and the
|
||||
first successful request per account is complimentary. Anonymous OOBE
|
||||
grants require a matching `oobeFeature` and allow one successful request
|
||||
per feature. Later attempts fail without falling through to paid billing.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RequestId"
|
||||
- name: capability
|
||||
@@ -2629,8 +2667,51 @@ components:
|
||||
type: ["string", "null"]
|
||||
enum: [oobe, null]
|
||||
description: |
|
||||
Optional server-audited billing purpose. `oobe` is valid only with
|
||||
`polish` and `dictation_polish`, and is complimentary once per account.
|
||||
Optional server-audited billing purpose. Account grants accept `oobe`
|
||||
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:
|
||||
type: object
|
||||
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.ReferralLookupPort
|
||||
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.services.ReferralOperations
|
||||
import com.osglab.account.features.referrals.services.ReferralService
|
||||
@@ -329,6 +334,7 @@ fun Application.module() {
|
||||
healthRoutes(koin.get())
|
||||
rateLimit(AUTH_RATE_LIMIT) {
|
||||
authRoutes(koin.get())
|
||||
oobeRoutes(koin.get())
|
||||
}
|
||||
rateLimit(ACCOUNT_RATE_LIMIT) {
|
||||
accountRoutes(koin.get())
|
||||
@@ -652,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
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<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
|
||||
single<GatewayAccessTokenPort> {
|
||||
val oobeGrants = get<OobeGrantService>()
|
||||
GatewayBearerIdentity(get(), oobeGrants::authenticate)
|
||||
}
|
||||
single<CreditReservationPort> {
|
||||
CreditReservationAdapter(
|
||||
creditService = get(),
|
||||
@@ -664,7 +689,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single {
|
||||
ProviderCatalog(configuredProviders(config, get()))
|
||||
}
|
||||
single { GatewayService(get(), get(), get(), get(), get()) }
|
||||
single { GatewayService(get(), get(), get(), get(), get(), get()) }
|
||||
single { GatewayReconciliationService(get(), get()) }
|
||||
single {
|
||||
InviteWebConfig(
|
||||
|
||||
@@ -148,6 +148,10 @@ data class AppConfig(
|
||||
if (production) "production" else "development",
|
||||
),
|
||||
),
|
||||
allowDevelopmentAppAttest = config.booleanOrDefault(
|
||||
"app.integrity.allowDevelopmentAppAttest",
|
||||
false,
|
||||
),
|
||||
challengeLifetimeSeconds = config.positiveLong(
|
||||
"app.integrity.challengeLifetimeSeconds",
|
||||
300,
|
||||
@@ -449,6 +453,7 @@ data class IntegrityConfig(
|
||||
val deviceCheckPolicy: IntegrityPolicy,
|
||||
val appAttestPolicy: IntegrityPolicy,
|
||||
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
|
||||
val allowDevelopmentAppAttest: Boolean = false,
|
||||
val challengeLifetimeSeconds: Long = 300,
|
||||
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
|
||||
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
||||
|
||||
@@ -31,6 +31,26 @@ enum class GatewayRequestPurpose {
|
||||
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
|
||||
enum class UsageMeter {
|
||||
@SerialName("llm_token")
|
||||
@@ -50,10 +70,14 @@ data class GatewayPrincipal(
|
||||
// Callers must grant capabilities explicitly. An identity with omitted
|
||||
// scopes is intentionally unable to invoke a managed provider.
|
||||
val scopes: Set<GatewayCapability> = emptySet(),
|
||||
val subjectType: GatewaySubjectType = GatewaySubjectType.ACCOUNT,
|
||||
) {
|
||||
// Kept as a compatibility name for the existing account-scoped persistence.
|
||||
val accountId: String
|
||||
get() = userId
|
||||
|
||||
val isOobe: Boolean
|
||||
get() = subjectType == GatewaySubjectType.OOBE
|
||||
}
|
||||
|
||||
typealias GatewaySubject = GatewayPrincipal
|
||||
@@ -68,6 +92,7 @@ data class TextGatewayRequest(
|
||||
val requestSource: GatewayRequestSource? = null,
|
||||
val taskKind: GatewayTaskKind? = null,
|
||||
val requestPurpose: GatewayRequestPurpose? = null,
|
||||
val oobeFeature: OobeFeature? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -176,6 +201,7 @@ data class TextProviderRequest(
|
||||
val stream: Boolean,
|
||||
override val requestSource: GatewayRequestSource? = null,
|
||||
override val requestPurpose: GatewayRequestPurpose? = null,
|
||||
val oobeFeature: OobeFeature? = null,
|
||||
) : ProviderRequest
|
||||
|
||||
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.GatewayService
|
||||
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.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -150,7 +152,15 @@ fun Route.configureGatewayRoutes(
|
||||
|
||||
get("/catalog") {
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -276,6 +286,7 @@ fun Route.configureGatewayRoutes(
|
||||
stream = body.stream,
|
||||
requestSource = body.requestSource,
|
||||
requestPurpose = body.requestPurpose,
|
||||
oobeFeature = body.oobeFeature,
|
||||
)
|
||||
|
||||
if (body.stream) {
|
||||
@@ -444,6 +455,20 @@ private suspend fun ApplicationCall.respondGatewayFailure(
|
||||
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(
|
||||
HttpStatusCode.PayloadTooLarge,
|
||||
"request_too_large",
|
||||
|
||||
@@ -226,6 +226,7 @@ class GatewayGrantService(
|
||||
|
||||
class GatewayBearerIdentity(
|
||||
private val grants: GatewayGrantService,
|
||||
private val authenticateOobe: suspend (String) -> GatewayPrincipal? = { null },
|
||||
) : GatewayAccessTokenPort {
|
||||
override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? {
|
||||
val token = call.request.headers[HttpHeaders.Authorization]
|
||||
@@ -234,7 +235,7 @@ class GatewayBearerIdentity(
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS }
|
||||
?: return null
|
||||
return grants.authenticate(token)
|
||||
return grants.authenticate(token) ?: authenticateOobe(token)
|
||||
}
|
||||
|
||||
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.providers.GatewayProvider
|
||||
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.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.time.Clock
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
class GatewayService(
|
||||
@@ -33,9 +39,11 @@ class GatewayService(
|
||||
private val grants: GatewayGrantPort,
|
||||
private val usageRecords: GatewayUsagePort,
|
||||
private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests,
|
||||
private val oobeRequests: OobeRepository? = null,
|
||||
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
|
||||
private val llmProviderTimeoutMillis: Long = 120_000L,
|
||||
private val asrProviderTimeoutMillis: Long = 360_000L,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
init {
|
||||
require(llmProviderTimeoutMillis > 0)
|
||||
@@ -59,14 +67,39 @@ class GatewayService(
|
||||
if (request.capability !in subject.scopes) {
|
||||
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)
|
||||
}
|
||||
|
||||
val provider = catalog.providerFor(request)
|
||||
val estimate = usageEstimator.estimate(request)
|
||||
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)
|
||||
complimentaryRequests.claim(
|
||||
accountId = subject.accountId,
|
||||
@@ -75,7 +108,7 @@ class GatewayService(
|
||||
requestId = request.requestId,
|
||||
) ?: throw ComplimentaryRequestUnavailableException(purpose)
|
||||
}
|
||||
val reservation = if (complimentaryClaim == null) {
|
||||
val reservation = if (complimentaryClaim == null && oobeClaim == null) {
|
||||
credits.reserve(
|
||||
accountId = subject.accountId,
|
||||
estimate = estimate,
|
||||
@@ -85,38 +118,45 @@ class GatewayService(
|
||||
null
|
||||
}
|
||||
try {
|
||||
usageRecords.claim(
|
||||
ProviderRequestMetadata(
|
||||
requestId = request.requestId,
|
||||
accountId = subject.accountId,
|
||||
reservationId = reservation?.id,
|
||||
providerId = provider.descriptor.id,
|
||||
capability = request.capability,
|
||||
requestSource = request.requestSource,
|
||||
requestPurpose = request.requestPurpose,
|
||||
),
|
||||
)
|
||||
if (!subject.isOobe) {
|
||||
usageRecords.claim(
|
||||
ProviderRequestMetadata(
|
||||
requestId = request.requestId,
|
||||
accountId = subject.accountId,
|
||||
reservationId = reservation?.id,
|
||||
providerId = provider.descriptor.id,
|
||||
capability = request.capability,
|
||||
requestSource = request.requestSource,
|
||||
requestPurpose = request.requestPurpose,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (replay: GatewayRequestAlreadyClaimedException) {
|
||||
// The existing claim owns the reservation. Releasing it here would
|
||||
// refund an in-flight or completed paid request. Complimentary
|
||||
// claims are newly acquired above and must not remain stranded.
|
||||
if (complimentaryClaim != null) {
|
||||
releaseAfterFailure(null, complimentaryClaim, replay)
|
||||
releaseAfterFailure(null, complimentaryClaim, oobeClaim, replay)
|
||||
}
|
||||
throw replay
|
||||
} catch (failure: Throwable) {
|
||||
releaseAfterFailure(reservation, complimentaryClaim, failure)
|
||||
releaseAfterFailure(reservation, complimentaryClaim, oobeClaim, failure)
|
||||
throw failure
|
||||
}
|
||||
|
||||
try {
|
||||
usageRecords.markStarted(subject.accountId, request.requestId)
|
||||
if (oobeClaim != null) {
|
||||
requireNotNull(oobeRequests).markStarted(oobeClaim)
|
||||
} else {
|
||||
usageRecords.markStarted(subject.accountId, request.requestId)
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
releaseAndRecord(
|
||||
subject.accountId,
|
||||
request.requestId,
|
||||
reservation,
|
||||
complimentaryClaim,
|
||||
oobeClaim,
|
||||
failure,
|
||||
)
|
||||
throw failure
|
||||
@@ -129,6 +169,7 @@ class GatewayService(
|
||||
estimate,
|
||||
reservation,
|
||||
complimentaryClaim,
|
||||
oobeClaim,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,6 +213,10 @@ class GatewayService(
|
||||
// Once upstream has completed, cancellation must not interrupt durable
|
||||
// metering. The reservation remains frozen if any settlement step fails.
|
||||
withContext(NonCancellable) {
|
||||
if (prepared.oobeClaim != null) {
|
||||
settleOobe(prepared, usage)
|
||||
return@withContext
|
||||
}
|
||||
if (prepared.complimentaryClaim != null) {
|
||||
settleComplimentary(prepared, usage)
|
||||
return@withContext
|
||||
@@ -214,6 +259,7 @@ class GatewayService(
|
||||
prepared.request.requestId,
|
||||
prepared.reservation,
|
||||
prepared.complimentaryClaim,
|
||||
prepared.oobeClaim,
|
||||
failure,
|
||||
)
|
||||
}
|
||||
@@ -224,11 +270,15 @@ class GatewayService(
|
||||
failure: Throwable,
|
||||
) {
|
||||
runCatching {
|
||||
usageRecords.markManualReview(
|
||||
prepared.subject.accountId,
|
||||
prepared.request.requestId,
|
||||
errorCode,
|
||||
)
|
||||
if (prepared.oobeClaim != null) {
|
||||
requireNotNull(oobeRequests).markManualReview(prepared.oobeClaim, errorCode)
|
||||
} else {
|
||||
usageRecords.markManualReview(
|
||||
prepared.subject.accountId,
|
||||
prepared.request.requestId,
|
||||
errorCode,
|
||||
)
|
||||
}
|
||||
}.onFailure(failure::addSuppressed)
|
||||
}
|
||||
|
||||
@@ -237,14 +287,20 @@ class GatewayService(
|
||||
requestId: String,
|
||||
reservation: CreditReservation?,
|
||||
complimentaryClaim: ComplimentaryRequestClaim?,
|
||||
oobeClaim: OobeRequestClaim?,
|
||||
failure: Throwable,
|
||||
): Unit = withContext(NonCancellable) {
|
||||
val released = if (complimentaryClaim != null) {
|
||||
runCatching { complimentaryRequests.release(complimentaryClaim) }
|
||||
} else {
|
||||
runCatching { credits.release(requireNotNull(reservation).id) }
|
||||
val released = when {
|
||||
oobeClaim != null -> runCatching {
|
||||
requireNotNull(oobeRequests).release(
|
||||
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 {
|
||||
usageRecords.markReleased(
|
||||
accountId,
|
||||
@@ -252,11 +308,17 @@ class GatewayService(
|
||||
failure::class.simpleName ?: "provider_error",
|
||||
)
|
||||
}.onFailure(failure::addSuppressed)
|
||||
} else {
|
||||
} else if (released.isFailure) {
|
||||
released.exceptionOrNull()?.let(failure::addSuppressed)
|
||||
runCatching {
|
||||
usageRecords.markManualReview(accountId, requestId, "release_pending")
|
||||
}.onFailure(failure::addSuppressed)
|
||||
if (oobeClaim != null) {
|
||||
runCatching {
|
||||
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) {
|
||||
if (usage.meter != estimate.meter) {
|
||||
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(
|
||||
reservation: CreditReservation?,
|
||||
complimentaryClaim: ComplimentaryRequestClaim?,
|
||||
oobeClaim: OobeRequestClaim?,
|
||||
failure: Throwable,
|
||||
): Unit = withContext(NonCancellable) {
|
||||
val released = if (complimentaryClaim != null) {
|
||||
runCatching { complimentaryRequests.release(complimentaryClaim) }
|
||||
} else {
|
||||
runCatching { credits.release(requireNotNull(reservation).id) }
|
||||
val released = when {
|
||||
oobeClaim != null -> runCatching {
|
||||
requireNotNull(oobeRequests).release(
|
||||
oobeClaim,
|
||||
failure::class.simpleName ?: "provider_error",
|
||||
)
|
||||
}
|
||||
complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) }
|
||||
else -> runCatching { credits.release(requireNotNull(reservation).id) }
|
||||
}
|
||||
released
|
||||
.onFailure(failure::addSuppressed)
|
||||
@@ -368,6 +461,7 @@ class GatewayService(
|
||||
|
||||
private companion object {
|
||||
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 reservation: CreditReservation?,
|
||||
val complimentaryClaim: ComplimentaryRequestClaim?,
|
||||
val oobeClaim: OobeRequestClaim?,
|
||||
)
|
||||
|
||||
class GatewayReconciliationService(
|
||||
|
||||
@@ -8,22 +8,35 @@ import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
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") {
|
||||
get("/live") {
|
||||
call.respond(HealthResponse(status = "UP"))
|
||||
call.respond(HealthResponse(status = "UP", buildSha = buildSha))
|
||||
}
|
||||
get("/ready") {
|
||||
val databaseReady = databaseFactory.isReady()
|
||||
|
||||
if (databaseReady) {
|
||||
call.respond(HealthResponse(status = "UP"))
|
||||
call.respond(HealthResponse(status = "UP", buildSha = buildSha))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN"))
|
||||
call.respond(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
HealthResponse(status = "DOWN", buildSha = buildSha),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
"${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
private val expectedAaguid = when (config.appleEnvironment) {
|
||||
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID
|
||||
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID
|
||||
private val allowedAaguids = buildList {
|
||||
add(
|
||||
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(
|
||||
@@ -197,7 +206,7 @@ class LibraryAppAttestCrypto(
|
||||
if (authenticatorData.signCount != 0L) {
|
||||
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")
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -78,4 +78,5 @@ app:
|
||||
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
|
||||
enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
|
||||
appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development"
|
||||
allowDevelopmentAppAttest: "$ALLOW_DEVELOPMENT_APP_ATTEST:false"
|
||||
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.database.username shouldBe "test"
|
||||
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") {
|
||||
|
||||
@@ -378,6 +378,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/integrity/challenges",
|
||||
"/v1/integrity/attest",
|
||||
"/v1/integrity/assert",
|
||||
"/v1/oobe/grants",
|
||||
"/v1/oobe/grants/refresh",
|
||||
"/v1/gateway/catalog",
|
||||
"/v1/gateway/grants",
|
||||
"/v1/gateway/grants/refresh",
|
||||
|
||||
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
runner shouldContain "APPLE_JWKS_URL=http://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 "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 "active smaller credit rates did not match the V10 contract"
|
||||
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") {
|
||||
val fixture = AppAttestFixture()
|
||||
val hash = sha256ForTest("cost-request".toByteArray())
|
||||
@@ -139,12 +156,16 @@ private class AppAttestFixture {
|
||||
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
|
||||
)
|
||||
|
||||
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto =
|
||||
fun crypto(
|
||||
nonce: ByteArray = expectedNonce(),
|
||||
allowDevelopment: Boolean = false,
|
||||
): LibraryAppAttestCrypto =
|
||||
LibraryAppAttestCrypto(
|
||||
IntegrityConfig(
|
||||
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
|
||||
appAttestPolicy = IntegrityPolicy.ENFORCE,
|
||||
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
|
||||
allowDevelopmentAppAttest = allowDevelopment,
|
||||
),
|
||||
AppAttestCertificateValidator {
|
||||
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
|
||||
@@ -189,8 +210,8 @@ private class AppAttestFixture {
|
||||
.EncodeToBytes()
|
||||
}
|
||||
|
||||
private fun expectedNonce(): ByteArray =
|
||||
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge))
|
||||
fun expectedNonce(aaguid: ByteArray = productionAaguid()): ByteArray =
|
||||
sha256ForTest(attestationAuthData(rpIdHash, aaguid) + sha256ForTest(challenge))
|
||||
|
||||
private fun productionAaguid(): ByteArray =
|
||||
"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)
|
||||
Reference in New Issue
Block a user