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