@astrale-os/cli 0.4.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- package/tsconfig.json +14 -0
package/src/lib/idp.ts
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { decodeJwt, type JWTPayload } from 'jose'
|
|
2
|
+
import { mkdir, readFile, readdir, unlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
import { paths } from './env'
|
|
7
|
+
import { atomicWrite } from './fs-atomic'
|
|
8
|
+
import { log } from './log'
|
|
9
|
+
import { IDPS_PATH, IDP_SESSIONS_DIR } from './paths'
|
|
10
|
+
import { validateName, validateUrl } from './validation'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when a refresh succeeds but the IdP mints an access token whose `aud`
|
|
14
|
+
* does not match the one the caller requested. Distinct from a transport/grant
|
|
15
|
+
* failure: the session is *healthy*, the IdP simply will not issue that
|
|
16
|
+
* audience (e.g. WorkOS AuthKit ignores the `audience` param and stamps a fixed
|
|
17
|
+
* API audience per environment). Re-login does not help — callers must target
|
|
18
|
+
* an instance whose audience equals `actual`, or reconfigure the IdP/bookmark.
|
|
19
|
+
*/
|
|
20
|
+
export class IdpAudienceMismatchError extends Error {
|
|
21
|
+
readonly requested: string
|
|
22
|
+
readonly actual: string | undefined
|
|
23
|
+
|
|
24
|
+
constructor(requested: string, actual: string | undefined) {
|
|
25
|
+
super(
|
|
26
|
+
`IdP minted an access token for audience ${actual ?? '(none)'} but ${requested} was required`,
|
|
27
|
+
)
|
|
28
|
+
this.name = 'IdpAudienceMismatchError'
|
|
29
|
+
this.requested = requested
|
|
30
|
+
this.actual = actual
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A token-endpoint request that the IdP answered with an OAuth error (or that
|
|
36
|
+
* failed at the HTTP layer). Carries the structured OAuth `error` code so
|
|
37
|
+
* callers can tell a definitively dead grant (`invalid_grant`) from a
|
|
38
|
+
* transient outage — the message string alone cannot.
|
|
39
|
+
*/
|
|
40
|
+
export class OAuthTokenError extends Error {
|
|
41
|
+
/** OAuth `error` code, e.g. `invalid_grant`. */
|
|
42
|
+
readonly code?: string
|
|
43
|
+
/** OAuth `error_description`. */
|
|
44
|
+
readonly description?: string
|
|
45
|
+
/** HTTP status of the token response. */
|
|
46
|
+
readonly status?: number
|
|
47
|
+
|
|
48
|
+
constructor(args: { code?: string; description?: string; status?: number }) {
|
|
49
|
+
super(
|
|
50
|
+
`OAuth token request failed: ${args.description ?? args.code ?? `HTTP ${args.status ?? '?'}`}`,
|
|
51
|
+
)
|
|
52
|
+
this.name = 'OAuthTokenError'
|
|
53
|
+
this.code = args.code
|
|
54
|
+
this.description = args.description
|
|
55
|
+
this.status = args.status
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RefreshFailureKind = 'session-ended' | 'org-rejected' | 'transient' | 'unknown'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify a `refreshSession` failure so callers only demand a re-login when
|
|
63
|
+
* the grant is actually dead. `org-rejected` = healthy session, wrong/stale
|
|
64
|
+
* org scope (re-login can't fix it); 5xx/429 and fetch-layer failures are
|
|
65
|
+
* transient — the cached session is likely still valid.
|
|
66
|
+
*/
|
|
67
|
+
export function classifyRefreshFailure(e: unknown): RefreshFailureKind {
|
|
68
|
+
if (e instanceof OAuthTokenError) {
|
|
69
|
+
if (
|
|
70
|
+
/not a member of the organization|organization not found/i.test(e.description ?? e.message)
|
|
71
|
+
) {
|
|
72
|
+
return 'org-rejected'
|
|
73
|
+
}
|
|
74
|
+
if (e.code === 'invalid_grant') return 'session-ended'
|
|
75
|
+
if (e.status !== undefined && (e.status >= 500 || e.status === 429)) return 'transient'
|
|
76
|
+
return 'unknown'
|
|
77
|
+
}
|
|
78
|
+
if (e instanceof Error) {
|
|
79
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') return 'transient'
|
|
80
|
+
// Node's fetch rejects network-layer failures as TypeError.
|
|
81
|
+
if (e instanceof TypeError) return 'transient'
|
|
82
|
+
// Bun stamps codes like ConnectionRefused/ConnectionClosed/FailedToOpenSocket;
|
|
83
|
+
// Node uses the classic ECONN*/ETIMEDOUT family.
|
|
84
|
+
const code = (e as { code?: string }).code
|
|
85
|
+
if (
|
|
86
|
+
typeof code === 'string' &&
|
|
87
|
+
/^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|Connection|FailedToOpenSocket|Timeout)/.test(
|
|
88
|
+
code,
|
|
89
|
+
)
|
|
90
|
+
) {
|
|
91
|
+
return 'transient'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return 'unknown'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const OidcMetadataSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
issuer: z.string().url(),
|
|
100
|
+
authorization_endpoint: z.string().url().optional(),
|
|
101
|
+
token_endpoint: z.string().url(),
|
|
102
|
+
jwks_uri: z.string().url(),
|
|
103
|
+
device_authorization_endpoint: z.string().url().optional(),
|
|
104
|
+
revocation_endpoint: z.string().url().optional(),
|
|
105
|
+
scopes_supported: z.array(z.string()).optional(),
|
|
106
|
+
grant_types_supported: z.array(z.string()).optional(),
|
|
107
|
+
response_types_supported: z.array(z.string()).optional(),
|
|
108
|
+
code_challenge_methods_supported: z.array(z.string()).optional(),
|
|
109
|
+
})
|
|
110
|
+
.passthrough()
|
|
111
|
+
|
|
112
|
+
export type OidcMetadata = z.infer<typeof OidcMetadataSchema>
|
|
113
|
+
|
|
114
|
+
export const IdpClientConfigSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
client_id: z.string().optional(),
|
|
117
|
+
client_secret_env: z.string().optional(),
|
|
118
|
+
redirect_uris: z.array(z.string().url()).optional(),
|
|
119
|
+
scope: z.string().optional(),
|
|
120
|
+
public: z.boolean().optional(),
|
|
121
|
+
token_response: z.enum(['oauth', 'workos-authkit']).optional(),
|
|
122
|
+
token_request_format: z.enum(['form', 'json']).optional(),
|
|
123
|
+
workos_application_id: z.string().optional(),
|
|
124
|
+
workos_application_type: z.enum(['oauth', 'm2m']).optional(),
|
|
125
|
+
})
|
|
126
|
+
.passthrough()
|
|
127
|
+
|
|
128
|
+
export type IdpClientConfig = z.infer<typeof IdpClientConfigSchema>
|
|
129
|
+
|
|
130
|
+
export const IdpIndexEntrySchema = z.object({
|
|
131
|
+
issuer: z.string().url(),
|
|
132
|
+
builtIn: z.boolean().optional(),
|
|
133
|
+
createdAt: z.string(),
|
|
134
|
+
updatedAt: z.string(),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
export const IdpStoreSchema = z.object({
|
|
138
|
+
idps: z.record(z.string(), IdpIndexEntrySchema),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
export type IdpStore = z.infer<typeof IdpStoreSchema>
|
|
142
|
+
export type IdpIndexEntry = z.infer<typeof IdpIndexEntrySchema>
|
|
143
|
+
|
|
144
|
+
export const IdpSessionSchema = z
|
|
145
|
+
.object({
|
|
146
|
+
identity: z.string(),
|
|
147
|
+
idp: z.string(),
|
|
148
|
+
issuer: z.string().url(),
|
|
149
|
+
subject: z.string(),
|
|
150
|
+
audience: z.string().optional(),
|
|
151
|
+
organizationId: z.string().optional(),
|
|
152
|
+
access_token: z.string(),
|
|
153
|
+
id_token: z.string().optional(),
|
|
154
|
+
refresh_token: z.string().optional(),
|
|
155
|
+
token_type: z.string().optional(),
|
|
156
|
+
scope: z.string().optional(),
|
|
157
|
+
expires_at: z.string().optional(),
|
|
158
|
+
claims: z.record(z.string(), z.unknown()).optional(),
|
|
159
|
+
/**
|
|
160
|
+
* Access tokens by audience. WorkOS mints one `aud` per token, so a
|
|
161
|
+
* session juggling several instances would otherwise burn a (single-use)
|
|
162
|
+
* refresh-token rotation on every instance flip. The top-level
|
|
163
|
+
* `access_token`/`expires_at` stay the most recently minted token for
|
|
164
|
+
* back-compat with older CLI builds.
|
|
165
|
+
*/
|
|
166
|
+
tokens: z
|
|
167
|
+
.record(
|
|
168
|
+
z.string(),
|
|
169
|
+
z.object({ access_token: z.string(), expires_at: z.string().optional() }).passthrough(),
|
|
170
|
+
)
|
|
171
|
+
.optional(),
|
|
172
|
+
updatedAt: z.string(),
|
|
173
|
+
})
|
|
174
|
+
.passthrough()
|
|
175
|
+
|
|
176
|
+
export type IdpSession = z.infer<typeof IdpSessionSchema>
|
|
177
|
+
|
|
178
|
+
export type TokenResponse = {
|
|
179
|
+
access_token?: string
|
|
180
|
+
accessToken?: string
|
|
181
|
+
id_token?: string
|
|
182
|
+
idToken?: string
|
|
183
|
+
refresh_token?: string
|
|
184
|
+
refreshToken?: string
|
|
185
|
+
token_type?: string
|
|
186
|
+
scope?: string
|
|
187
|
+
expires_in?: number
|
|
188
|
+
expiresIn?: number
|
|
189
|
+
user?: { id?: string; [key: string]: unknown }
|
|
190
|
+
organization_id?: string
|
|
191
|
+
authentication_method?: string
|
|
192
|
+
error?: string
|
|
193
|
+
error_description?: string
|
|
194
|
+
[key: string]: unknown
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type DeviceAuthorizationResponse = {
|
|
198
|
+
device_code: string
|
|
199
|
+
user_code?: string
|
|
200
|
+
verification_uri?: string
|
|
201
|
+
verification_uri_complete?: string
|
|
202
|
+
expires_in: number
|
|
203
|
+
interval?: number
|
|
204
|
+
message?: string
|
|
205
|
+
[key: string]: unknown
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type IdpConfig = {
|
|
209
|
+
name: string
|
|
210
|
+
entry: IdpIndexEntry
|
|
211
|
+
metadata: OidcMetadata
|
|
212
|
+
client: IdpClientConfig
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const BUILTIN_WORKOS_IDP_NAME = 'workos'
|
|
216
|
+
const DEFAULT_WORKOS_API_HOST = 'https://api.workos.com'
|
|
217
|
+
const DEFAULT_WORKOS_CLIENT_ID = 'client_01KC29HEGD7B40TV2C4QZ436BG'
|
|
218
|
+
const WORKOS_CLIENT_ID_ENV_NAMES = ['WORKOS_CLIENT_ID', 'VITE_WORKOS_CLIENT_ID'] as const
|
|
219
|
+
|
|
220
|
+
export function idpDir(name: string): string {
|
|
221
|
+
return paths.idpDir(name)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function idpMetadataPath(name: string): string {
|
|
225
|
+
return join(idpDir(name), 'metadata.json')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function idpClientPath(name: string): string {
|
|
229
|
+
return join(idpDir(name), 'client.json')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function idpSessionPath(identityName: string): string {
|
|
233
|
+
validateName(identityName, 'Identity')
|
|
234
|
+
return paths.idpSession(identityName)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function readIdpStore(): Promise<IdpStore> {
|
|
238
|
+
try {
|
|
239
|
+
const raw = await readFile(IDPS_PATH, 'utf-8')
|
|
240
|
+
return IdpStoreSchema.parse(JSON.parse(raw))
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (e instanceof z.ZodError) {
|
|
243
|
+
log.warn(`Invalid IdP index at ${IDPS_PATH} — using empty registry`)
|
|
244
|
+
} else if ((e as { code?: string }).code !== 'ENOENT') {
|
|
245
|
+
log.warn(`Could not read IdP index at ${IDPS_PATH} — using empty registry`)
|
|
246
|
+
}
|
|
247
|
+
return { idps: {} }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function writeIdpStore(store: IdpStore): Promise<void> {
|
|
252
|
+
await mkdir(dirname(IDPS_PATH), { recursive: true })
|
|
253
|
+
await atomicWrite(IDPS_PATH, JSON.stringify(store, null, 2) + '\n')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function readIdpConfig(name: string): Promise<IdpConfig> {
|
|
257
|
+
validateName(name, 'IdP')
|
|
258
|
+
const store = await readIdpStore()
|
|
259
|
+
const entry = store.idps[name]
|
|
260
|
+
if (!entry)
|
|
261
|
+
throw new Error(`IdP "${name}" not found. Run: astrale idp add ${name} --issuer <url>`)
|
|
262
|
+
const [metadataRaw, clientRaw] = await Promise.all([
|
|
263
|
+
readFile(idpMetadataPath(name), 'utf-8'),
|
|
264
|
+
readFile(idpClientPath(name), 'utf-8').catch((e) => {
|
|
265
|
+
if ((e as { code?: string }).code === 'ENOENT') return '{}'
|
|
266
|
+
throw e
|
|
267
|
+
}),
|
|
268
|
+
])
|
|
269
|
+
return {
|
|
270
|
+
name,
|
|
271
|
+
entry,
|
|
272
|
+
metadata: OidcMetadataSchema.parse(JSON.parse(metadataRaw)),
|
|
273
|
+
client: IdpClientConfigSchema.parse(JSON.parse(clientRaw)),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function readIdpConfigOrBuiltin(
|
|
278
|
+
name: string,
|
|
279
|
+
opts: { clientId?: string; persist?: boolean } = {},
|
|
280
|
+
): Promise<IdpConfig> {
|
|
281
|
+
validateName(name, 'IdP')
|
|
282
|
+
const store = await readIdpStore()
|
|
283
|
+
if (store.idps[name]) {
|
|
284
|
+
return await readIdpConfig(name)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const builtin = builtinIdpConfig(name, opts.clientId)
|
|
288
|
+
if (!builtin) {
|
|
289
|
+
throw new Error(`IdP "${name}" not found. Run: astrale idp add ${name} --issuer <url>`)
|
|
290
|
+
}
|
|
291
|
+
if (!opts.persist) return builtin
|
|
292
|
+
return upsertIdpConfig({
|
|
293
|
+
name: builtin.name,
|
|
294
|
+
metadata: builtin.metadata,
|
|
295
|
+
client: builtin.client,
|
|
296
|
+
builtIn: true,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function listIdpConfigs(): Promise<IdpConfig[]> {
|
|
301
|
+
const store = await readIdpStore()
|
|
302
|
+
const entries = await Promise.all(
|
|
303
|
+
Object.keys(store.idps).map((name) => readIdpConfig(name).catch(() => undefined)),
|
|
304
|
+
)
|
|
305
|
+
const configs = entries.filter((entry): entry is IdpConfig => !!entry)
|
|
306
|
+
if (!store.idps[BUILTIN_WORKOS_IDP_NAME]) {
|
|
307
|
+
const workos = builtinIdpConfig(BUILTIN_WORKOS_IDP_NAME)
|
|
308
|
+
if (workos) configs.push(workos)
|
|
309
|
+
}
|
|
310
|
+
return configs
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function upsertIdpConfig(args: {
|
|
314
|
+
name: string
|
|
315
|
+
metadata: OidcMetadata
|
|
316
|
+
client?: IdpClientConfig
|
|
317
|
+
builtIn?: boolean
|
|
318
|
+
}): Promise<IdpConfig> {
|
|
319
|
+
validateName(args.name, 'IdP')
|
|
320
|
+
const store = await readIdpStore()
|
|
321
|
+
const now = new Date().toISOString()
|
|
322
|
+
const existing = store.idps[args.name]
|
|
323
|
+
store.idps[args.name] = {
|
|
324
|
+
issuer: args.metadata.issuer,
|
|
325
|
+
builtIn: args.builtIn ?? existing?.builtIn,
|
|
326
|
+
createdAt: existing?.createdAt ?? now,
|
|
327
|
+
updatedAt: now,
|
|
328
|
+
}
|
|
329
|
+
await mkdir(idpDir(args.name), { recursive: true })
|
|
330
|
+
await Promise.all([
|
|
331
|
+
atomicWrite(idpMetadataPath(args.name), JSON.stringify(args.metadata, null, 2) + '\n'),
|
|
332
|
+
atomicWrite(idpClientPath(args.name), JSON.stringify(args.client ?? {}, null, 2) + '\n'),
|
|
333
|
+
writeIdpStore(store),
|
|
334
|
+
])
|
|
335
|
+
return readIdpConfig(args.name)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function removeIdpConfig(name: string): Promise<void> {
|
|
339
|
+
validateName(name, 'IdP')
|
|
340
|
+
const store = await readIdpStore()
|
|
341
|
+
const entry = store.idps[name]
|
|
342
|
+
if (!entry) throw new Error(`IdP "${name}" not found`)
|
|
343
|
+
if (entry.builtIn) throw new Error(`Cannot remove built-in IdP "${name}"`)
|
|
344
|
+
delete store.idps[name]
|
|
345
|
+
await writeIdpStore(store)
|
|
346
|
+
await unlink(idpMetadataPath(name)).catch(() => undefined)
|
|
347
|
+
await unlink(idpClientPath(name)).catch(() => undefined)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function fetchOidcMetadata(issuer: string): Promise<OidcMetadata> {
|
|
351
|
+
validateUrl(issuer)
|
|
352
|
+
const discoveryUrl = new URL('/.well-known/openid-configuration', issuer)
|
|
353
|
+
const response = await fetch(discoveryUrl)
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`)
|
|
356
|
+
}
|
|
357
|
+
const body = await response.json()
|
|
358
|
+
let metadata = OidcMetadataSchema.parse(body)
|
|
359
|
+
if (normalizeIssuer(metadata.issuer) !== normalizeIssuer(issuer)) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`OIDC issuer mismatch: discovery returned ${metadata.issuer}, expected ${issuer}`,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
if (!metadata.device_authorization_endpoint) {
|
|
365
|
+
const oauthMetadata = await fetchOAuthAuthorizationServerMetadata(issuer).catch(() => undefined)
|
|
366
|
+
if (oauthMetadata) metadata = OidcMetadataSchema.parse({ ...metadata, ...oauthMetadata })
|
|
367
|
+
}
|
|
368
|
+
return metadata
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function workosAuthKitMetadata(
|
|
372
|
+
apiHost = 'https://api.workos.com',
|
|
373
|
+
clientId?: string,
|
|
374
|
+
): OidcMetadata {
|
|
375
|
+
validateUrl(apiHost)
|
|
376
|
+
const base = apiHost.replace(/\/+$/, '')
|
|
377
|
+
return OidcMetadataSchema.parse({
|
|
378
|
+
issuer: base,
|
|
379
|
+
authorization_endpoint: `${base}/user_management/authorize`,
|
|
380
|
+
token_endpoint: `${base}/user_management/authenticate`,
|
|
381
|
+
device_authorization_endpoint: `${base}/user_management/authorize/device`,
|
|
382
|
+
jwks_uri: clientId ? `${base}/sso/jwks/${clientId}` : `${base}/sso/jwks`,
|
|
383
|
+
grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code'],
|
|
384
|
+
response_types_supported: ['code'],
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function builtinIdpConfig(
|
|
389
|
+
name: string,
|
|
390
|
+
clientIdOverride?: string,
|
|
391
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
392
|
+
): IdpConfig | null {
|
|
393
|
+
if (name !== BUILTIN_WORKOS_IDP_NAME) return null
|
|
394
|
+
const clientId = clientIdOverride ?? workosClientIdFromEnv(env)
|
|
395
|
+
if (!clientId) return null
|
|
396
|
+
const apiHost = env.WORKOS_API_HOSTNAME ?? DEFAULT_WORKOS_API_HOST
|
|
397
|
+
const now = new Date().toISOString()
|
|
398
|
+
return {
|
|
399
|
+
name: BUILTIN_WORKOS_IDP_NAME,
|
|
400
|
+
entry: {
|
|
401
|
+
issuer: apiHost.replace(/\/+$/, ''),
|
|
402
|
+
builtIn: true,
|
|
403
|
+
createdAt: now,
|
|
404
|
+
updatedAt: now,
|
|
405
|
+
},
|
|
406
|
+
metadata: workosAuthKitMetadata(apiHost, clientId),
|
|
407
|
+
client: {
|
|
408
|
+
client_id: clientId,
|
|
409
|
+
public: true,
|
|
410
|
+
token_response: 'workos-authkit',
|
|
411
|
+
token_request_format: 'json',
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function workosClientIdFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
417
|
+
return (
|
|
418
|
+
WORKOS_CLIENT_ID_ENV_NAMES.map((name) => env[name]).find(
|
|
419
|
+
(value): value is string => typeof value === 'string' && value.length > 0,
|
|
420
|
+
) ?? DEFAULT_WORKOS_CLIENT_ID
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function fetchOAuthAuthorizationServerMetadata(
|
|
425
|
+
issuer: string,
|
|
426
|
+
): Promise<Partial<OidcMetadata> | undefined> {
|
|
427
|
+
const discoveryUrl = new URL('/.well-known/oauth-authorization-server', issuer)
|
|
428
|
+
const response = await fetch(discoveryUrl)
|
|
429
|
+
if (!response.ok) return undefined
|
|
430
|
+
const body = (await response.json()) as Partial<OidcMetadata>
|
|
431
|
+
if (typeof body.issuer === 'string' && normalizeIssuer(body.issuer) !== normalizeIssuer(issuer)) {
|
|
432
|
+
return undefined
|
|
433
|
+
}
|
|
434
|
+
return body
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function postForm(url: string, params: URLSearchParams): Promise<TokenResponse> {
|
|
438
|
+
const response = await fetch(url, {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
441
|
+
body: params,
|
|
442
|
+
})
|
|
443
|
+
return tokenResponseFrom(response)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function postJson(
|
|
447
|
+
url: string,
|
|
448
|
+
bodyInput: Record<string, string>,
|
|
449
|
+
): Promise<TokenResponse> {
|
|
450
|
+
const response = await fetch(url, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify(bodyInput),
|
|
454
|
+
})
|
|
455
|
+
return tokenResponseFrom(response)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function tokenResponseFrom(response: Response): Promise<TokenResponse> {
|
|
459
|
+
const text = await response.text()
|
|
460
|
+
let body: TokenResponse = {}
|
|
461
|
+
try {
|
|
462
|
+
body = text ? (JSON.parse(text) as TokenResponse) : {}
|
|
463
|
+
} catch {
|
|
464
|
+
// Non-JSON body (e.g. an HTML 502 from a proxy): surface the HTTP failure,
|
|
465
|
+
// keep a snippet for diagnostics.
|
|
466
|
+
throw new OAuthTokenError({
|
|
467
|
+
status: response.status,
|
|
468
|
+
description: `HTTP ${response.status} — non-JSON response: ${text.slice(0, 200)}`,
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
if (!response.ok || body.error) {
|
|
472
|
+
throw new OAuthTokenError({
|
|
473
|
+
code: body.error,
|
|
474
|
+
description: body.error_description,
|
|
475
|
+
status: response.status,
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
return body
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function tokenExpiresAt(token: TokenResponse): string | undefined {
|
|
482
|
+
const expiresIn =
|
|
483
|
+
typeof token.expires_in === 'number'
|
|
484
|
+
? token.expires_in
|
|
485
|
+
: typeof token.expiresIn === 'number'
|
|
486
|
+
? token.expiresIn
|
|
487
|
+
: undefined
|
|
488
|
+
if (typeof expiresIn === 'number') return new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
489
|
+
return tokenExpiresAtFromJwt(token.access_token ?? token.accessToken ?? token.id_token)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function decodeTokenClaims(token: string | undefined): JWTPayload | undefined {
|
|
493
|
+
if (!token) return undefined
|
|
494
|
+
try {
|
|
495
|
+
return decodeJwt(token)
|
|
496
|
+
} catch {
|
|
497
|
+
return undefined
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function tokenAudienceMatches(token: string | undefined, audience: string): boolean {
|
|
502
|
+
const actual = decodeTokenClaims(token)?.aud
|
|
503
|
+
if (Array.isArray(actual)) return actual.includes(audience)
|
|
504
|
+
return actual === audience
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function tokenExpiresAtFromJwt(token: string | undefined): string | undefined {
|
|
508
|
+
const claims = decodeTokenClaims(token)
|
|
509
|
+
return typeof claims?.exp === 'number' ? new Date(claims.exp * 1000).toISOString() : undefined
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function subjectFromToken(token: TokenResponse, fallback: string): string {
|
|
513
|
+
const claims = decodeTokenClaims(token.id_token ?? token.access_token)
|
|
514
|
+
const sub = claims?.sub
|
|
515
|
+
return typeof sub === 'string' && sub ? sub : (token.user?.id ?? fallback)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function issuerFromToken(token: TokenResponse, fallback: string): string {
|
|
519
|
+
const claims = decodeTokenClaims(token.id_token ?? token.access_token)
|
|
520
|
+
const iss = claims?.iss
|
|
521
|
+
return typeof iss === 'string' && iss ? iss : fallback
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function normalizeTokenResponse(token: TokenResponse): TokenResponse {
|
|
525
|
+
return {
|
|
526
|
+
...token,
|
|
527
|
+
access_token: token.access_token ?? token.accessToken,
|
|
528
|
+
id_token: token.id_token ?? token.idToken,
|
|
529
|
+
refresh_token: token.refresh_token ?? token.refreshToken,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function identityNameFromClaims(claims: JWTPayload | undefined, fallback: string): string {
|
|
534
|
+
const preferred = [claims?.email, claims?.preferred_username, claims?.sub, fallback].find(
|
|
535
|
+
(v) => typeof v === 'string' && v.length > 0,
|
|
536
|
+
) as string
|
|
537
|
+
const normalized = preferred.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
538
|
+
return normalized || fallback
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function saveIdpSession(session: IdpSession): Promise<void> {
|
|
542
|
+
const path = idpSessionPath(session.identity)
|
|
543
|
+
await mkdir(dirname(path), { recursive: true })
|
|
544
|
+
await atomicWrite(path, JSON.stringify(session, null, 2) + '\n')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function readIdpSession(identityName: string): Promise<IdpSession | null> {
|
|
548
|
+
try {
|
|
549
|
+
const raw = await readFile(idpSessionPath(identityName), 'utf-8')
|
|
550
|
+
return IdpSessionSchema.parse(JSON.parse(raw))
|
|
551
|
+
} catch (e) {
|
|
552
|
+
if ((e as { code?: string }).code === 'ENOENT') return null
|
|
553
|
+
if (e instanceof z.ZodError) {
|
|
554
|
+
throw new Error(`Invalid IdP session cache for "${identityName}"`)
|
|
555
|
+
}
|
|
556
|
+
throw e
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function deleteIdpSession(identityName: string): Promise<void> {
|
|
561
|
+
await unlink(idpSessionPath(identityName)).catch(() => undefined)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function listIdpSessions(): Promise<IdpSession[]> {
|
|
565
|
+
const out: IdpSession[] = []
|
|
566
|
+
let entries: string[] = []
|
|
567
|
+
try {
|
|
568
|
+
entries = (await readdir(IDP_SESSIONS_DIR)).filter((entry) => entry.endsWith('.json'))
|
|
569
|
+
} catch {
|
|
570
|
+
return out
|
|
571
|
+
}
|
|
572
|
+
for (const entry of entries) {
|
|
573
|
+
const name = entry.replace(/\.json$/, '')
|
|
574
|
+
const session = await readIdpSession(name).catch(() => null)
|
|
575
|
+
if (session) out.push(session)
|
|
576
|
+
}
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function isSessionExpired(
|
|
581
|
+
session: Pick<IdpSession, 'expires_at'> & { access_token?: string },
|
|
582
|
+
skewMs = 60_000,
|
|
583
|
+
): boolean {
|
|
584
|
+
const expiresAt = session.expires_at ?? tokenExpiresAtFromJwt(session.access_token)
|
|
585
|
+
if (!expiresAt) return false
|
|
586
|
+
return new Date(expiresAt).getTime() <= Date.now() + skewMs
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* A still-fresh access token usable for `audience`, or undefined when a
|
|
591
|
+
* refresh is needed. Checks the per-audience `tokens` map first, then the
|
|
592
|
+
* top-level token's own `aud` claim. Without an `audience`, freshness of the
|
|
593
|
+
* top-level token is the only requirement.
|
|
594
|
+
*/
|
|
595
|
+
export function accessTokenForAudience(session: IdpSession, audience?: string): string | undefined {
|
|
596
|
+
if (audience === undefined) {
|
|
597
|
+
return isSessionExpired(session) ? undefined : session.access_token
|
|
598
|
+
}
|
|
599
|
+
const entry = session.tokens?.[audience]
|
|
600
|
+
if (entry && !isSessionExpired(entry)) return entry.access_token
|
|
601
|
+
if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
|
|
602
|
+
return session.access_token
|
|
603
|
+
}
|
|
604
|
+
return undefined
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Fold a freshly minted access token into the per-audience map under every
|
|
609
|
+
* `aud` it carries, dropping entries that have already expired.
|
|
610
|
+
*/
|
|
611
|
+
export function withCachedToken(
|
|
612
|
+
tokens: IdpSession['tokens'],
|
|
613
|
+
accessToken: string,
|
|
614
|
+
expiresAt: string | undefined,
|
|
615
|
+
): IdpSession['tokens'] {
|
|
616
|
+
const next: NonNullable<IdpSession['tokens']> = {}
|
|
617
|
+
for (const [aud, entry] of Object.entries(tokens ?? {})) {
|
|
618
|
+
if (!isSessionExpired(entry)) next[aud] = entry
|
|
619
|
+
}
|
|
620
|
+
const aud = decodeTokenClaims(accessToken)?.aud
|
|
621
|
+
const audiences = Array.isArray(aud) ? aud : typeof aud === 'string' ? [aud] : []
|
|
622
|
+
for (const audience of audiences) {
|
|
623
|
+
next[audience] = { access_token: accessToken, expires_at: expiresAt }
|
|
624
|
+
}
|
|
625
|
+
return Object.keys(next).length > 0 ? next : undefined
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function requireClientId(idp: IdpConfig, override?: string): string {
|
|
629
|
+
const clientId = override ?? idp.client.client_id
|
|
630
|
+
if (!clientId) {
|
|
631
|
+
throw new Error(`IdP "${idp.name}" has no client_id. Re-run idp add with --client-id.`)
|
|
632
|
+
}
|
|
633
|
+
return clientId
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function resolveClientSecret(idp: IdpConfig, overrideEnv?: string): string | undefined {
|
|
637
|
+
const envName = overrideEnv ?? idp.client.client_secret_env
|
|
638
|
+
if (!envName) return undefined
|
|
639
|
+
const secret = process.env[envName]
|
|
640
|
+
if (!secret) throw new Error(`Client secret env var ${envName} is not set`)
|
|
641
|
+
return secret
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function refreshSession(
|
|
645
|
+
identityName: string,
|
|
646
|
+
session: IdpSession,
|
|
647
|
+
opts: { readonly audience?: string; readonly organizationId?: string } = {},
|
|
648
|
+
): Promise<IdpSession> {
|
|
649
|
+
if (!session.refresh_token)
|
|
650
|
+
throw new Error(`IdP session for "${identityName}" has no refresh token`)
|
|
651
|
+
const idp = await readIdpConfig(session.idp)
|
|
652
|
+
const params = new URLSearchParams({
|
|
653
|
+
grant_type: 'refresh_token',
|
|
654
|
+
refresh_token: session.refresh_token,
|
|
655
|
+
client_id: requireClientId(idp),
|
|
656
|
+
})
|
|
657
|
+
const audience = opts.audience ?? session.audience
|
|
658
|
+
if (audience) params.set('audience', audience)
|
|
659
|
+
const organizationId = opts.organizationId ?? session.organizationId
|
|
660
|
+
if (organizationId) params.set('organization_id', organizationId)
|
|
661
|
+
if (process.env.ASTRALE_DEBUG_ORG)
|
|
662
|
+
console.error(
|
|
663
|
+
'[debug-org] refresh',
|
|
664
|
+
JSON.stringify({
|
|
665
|
+
audience,
|
|
666
|
+
organizationId,
|
|
667
|
+
sub: (session.claims as { sub?: string } | undefined)?.sub,
|
|
668
|
+
}),
|
|
669
|
+
)
|
|
670
|
+
const secret = resolveClientSecret(idp)
|
|
671
|
+
if (secret) params.set('client_secret', secret)
|
|
672
|
+
const token = normalizeTokenResponse(
|
|
673
|
+
idp.client.token_request_format === 'json'
|
|
674
|
+
? await postJson(idp.metadata.token_endpoint, Object.fromEntries(params))
|
|
675
|
+
: await postForm(idp.metadata.token_endpoint, params),
|
|
676
|
+
)
|
|
677
|
+
if (!token.access_token) throw new Error('Refresh response did not include access_token')
|
|
678
|
+
const claims = decodeTokenClaims(token.id_token ?? token.access_token)
|
|
679
|
+
const expiresAt = tokenExpiresAt(token)
|
|
680
|
+
const next: IdpSession = {
|
|
681
|
+
...session,
|
|
682
|
+
access_token: token.access_token,
|
|
683
|
+
id_token: token.id_token ?? session.id_token,
|
|
684
|
+
refresh_token: token.refresh_token ?? session.refresh_token,
|
|
685
|
+
token_type: token.token_type ?? session.token_type,
|
|
686
|
+
scope: token.scope ?? session.scope,
|
|
687
|
+
audience,
|
|
688
|
+
organizationId: organizationId ?? session.organizationId,
|
|
689
|
+
expires_at: expiresAt,
|
|
690
|
+
tokens: withCachedToken(session.tokens, token.access_token, expiresAt),
|
|
691
|
+
claims: claims ? (claims as Record<string, unknown>) : session.claims,
|
|
692
|
+
updatedAt: new Date().toISOString(),
|
|
693
|
+
}
|
|
694
|
+
// Persist the rotated session BEFORE the audience check. WorkOS refresh tokens
|
|
695
|
+
// are single-use: the token request above already exchanged the old one, so the
|
|
696
|
+
// new refresh_token must be saved even when the audience doesn't match — else
|
|
697
|
+
// the cache is stranded on the now-invalid old token and every subsequent
|
|
698
|
+
// refresh dies with "Refresh token already exchanged".
|
|
699
|
+
await saveIdpSession(next)
|
|
700
|
+
if (audience && !tokenAudienceMatches(next.access_token, audience)) {
|
|
701
|
+
const actual = decodeTokenClaims(next.access_token)?.aud
|
|
702
|
+
throw new IdpAudienceMismatchError(
|
|
703
|
+
audience,
|
|
704
|
+
Array.isArray(actual) ? actual.join(', ') : (actual as string | undefined),
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
return next
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export async function requestClientCredentials(args: {
|
|
711
|
+
idp: IdpConfig
|
|
712
|
+
clientId?: string
|
|
713
|
+
clientSecretEnv?: string
|
|
714
|
+
scope?: string
|
|
715
|
+
audience?: string
|
|
716
|
+
}): Promise<TokenResponse> {
|
|
717
|
+
const clientId = requireClientId(args.idp, args.clientId)
|
|
718
|
+
const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
|
|
719
|
+
if (!secret) throw new Error('Client credentials flow requires a client secret env var')
|
|
720
|
+
const params = new URLSearchParams({
|
|
721
|
+
grant_type: 'client_credentials',
|
|
722
|
+
client_id: clientId,
|
|
723
|
+
client_secret: secret,
|
|
724
|
+
})
|
|
725
|
+
if (args.scope) params.set('scope', args.scope)
|
|
726
|
+
if (args.audience) params.set('audience', args.audience)
|
|
727
|
+
return postForm(args.idp.metadata.token_endpoint, params)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export async function requestDeviceAuthorization(args: {
|
|
731
|
+
idp: IdpConfig
|
|
732
|
+
clientId?: string
|
|
733
|
+
scope?: string
|
|
734
|
+
audience?: string
|
|
735
|
+
}): Promise<DeviceAuthorizationResponse> {
|
|
736
|
+
const endpoint = args.idp.metadata.device_authorization_endpoint
|
|
737
|
+
if (!endpoint)
|
|
738
|
+
throw new Error(`IdP "${args.idp.name}" does not advertise device_authorization_endpoint`)
|
|
739
|
+
const params = new URLSearchParams({
|
|
740
|
+
client_id: requireClientId(args.idp, args.clientId),
|
|
741
|
+
})
|
|
742
|
+
if (args.scope) params.set('scope', args.scope)
|
|
743
|
+
if (args.audience) params.set('audience', args.audience)
|
|
744
|
+
const response =
|
|
745
|
+
args.idp.client.token_request_format === 'json'
|
|
746
|
+
? await fetch(endpoint, {
|
|
747
|
+
method: 'POST',
|
|
748
|
+
headers: { 'Content-Type': 'application/json' },
|
|
749
|
+
body: JSON.stringify(Object.fromEntries(params)),
|
|
750
|
+
})
|
|
751
|
+
: await fetch(endpoint, {
|
|
752
|
+
method: 'POST',
|
|
753
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
754
|
+
body: params,
|
|
755
|
+
})
|
|
756
|
+
const body = (await response.json()) as DeviceAuthorizationResponse & {
|
|
757
|
+
error?: string
|
|
758
|
+
error_description?: string
|
|
759
|
+
}
|
|
760
|
+
if (!response.ok || body.error) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
body.error_description ??
|
|
763
|
+
body.error ??
|
|
764
|
+
`Device authorization failed: HTTP ${response.status}`,
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
return body
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export async function pollDeviceToken(args: {
|
|
771
|
+
idp: IdpConfig
|
|
772
|
+
deviceCode: string
|
|
773
|
+
clientId?: string
|
|
774
|
+
clientSecretEnv?: string
|
|
775
|
+
intervalSec?: number
|
|
776
|
+
expiresInSec: number
|
|
777
|
+
}): Promise<TokenResponse> {
|
|
778
|
+
const clientId = requireClientId(args.idp, args.clientId)
|
|
779
|
+
const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
|
|
780
|
+
let intervalMs = Math.max(args.intervalSec ?? 5, 1) * 1000
|
|
781
|
+
const deadline = Date.now() + args.expiresInSec * 1000
|
|
782
|
+
while (Date.now() < deadline) {
|
|
783
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
784
|
+
const params = new URLSearchParams({
|
|
785
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
786
|
+
device_code: args.deviceCode,
|
|
787
|
+
client_id: clientId,
|
|
788
|
+
})
|
|
789
|
+
if (secret) params.set('client_secret', secret)
|
|
790
|
+
const response =
|
|
791
|
+
args.idp.client.token_request_format === 'json'
|
|
792
|
+
? await fetch(args.idp.metadata.token_endpoint, {
|
|
793
|
+
method: 'POST',
|
|
794
|
+
headers: { 'Content-Type': 'application/json' },
|
|
795
|
+
body: JSON.stringify(Object.fromEntries(params)),
|
|
796
|
+
})
|
|
797
|
+
: await fetch(args.idp.metadata.token_endpoint, {
|
|
798
|
+
method: 'POST',
|
|
799
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
800
|
+
body: params,
|
|
801
|
+
})
|
|
802
|
+
const body = (await response.json()) as TokenResponse
|
|
803
|
+
const token = normalizeTokenResponse(body)
|
|
804
|
+
if (response.ok && token.access_token) return token
|
|
805
|
+
if (body.error === 'authorization_pending') continue
|
|
806
|
+
if (body.error === 'slow_down') {
|
|
807
|
+
intervalMs += 5_000
|
|
808
|
+
continue
|
|
809
|
+
}
|
|
810
|
+
throw new Error(
|
|
811
|
+
body.error_description ??
|
|
812
|
+
body.error ??
|
|
813
|
+
`Device token request failed: HTTP ${response.status}`,
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
throw new Error('Device authorization expired before completion')
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export async function exchangeAuthorizationCode(args: {
|
|
820
|
+
idp: IdpConfig
|
|
821
|
+
code: string
|
|
822
|
+
redirectUri: string
|
|
823
|
+
codeVerifier?: string
|
|
824
|
+
clientId?: string
|
|
825
|
+
clientSecretEnv?: string
|
|
826
|
+
}): Promise<TokenResponse> {
|
|
827
|
+
const params = new URLSearchParams({
|
|
828
|
+
grant_type: 'authorization_code',
|
|
829
|
+
code: args.code,
|
|
830
|
+
redirect_uri: args.redirectUri,
|
|
831
|
+
client_id: requireClientId(args.idp, args.clientId),
|
|
832
|
+
})
|
|
833
|
+
if (args.codeVerifier) params.set('code_verifier', args.codeVerifier)
|
|
834
|
+
const secret = resolveClientSecret(args.idp, args.clientSecretEnv)
|
|
835
|
+
if (secret) params.set('client_secret', secret)
|
|
836
|
+
return postForm(args.idp.metadata.token_endpoint, params)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export type WorkosApplication = {
|
|
840
|
+
id: string
|
|
841
|
+
client_id?: string
|
|
842
|
+
name?: string
|
|
843
|
+
application_type?: 'oauth' | 'm2m'
|
|
844
|
+
scopes?: string[]
|
|
845
|
+
redirect_uris?: Array<{ uri?: string; default?: boolean }>
|
|
846
|
+
uses_pkce?: boolean
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export async function fetchWorkosApplication(args: {
|
|
850
|
+
apiKeyEnv: string
|
|
851
|
+
app: string
|
|
852
|
+
}): Promise<WorkosApplication> {
|
|
853
|
+
const apiKey = process.env[args.apiKeyEnv]
|
|
854
|
+
if (!apiKey) throw new Error(`WorkOS API key env var ${args.apiKeyEnv} is not set`)
|
|
855
|
+
const response = await fetch(
|
|
856
|
+
`https://api.workos.com/connect/applications/${encodeURIComponent(args.app)}`,
|
|
857
|
+
{
|
|
858
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
859
|
+
},
|
|
860
|
+
)
|
|
861
|
+
const body = (await response.json()) as {
|
|
862
|
+
error?: string
|
|
863
|
+
message?: string
|
|
864
|
+
connect_application?: WorkosApplication
|
|
865
|
+
} & WorkosApplication
|
|
866
|
+
if (!response.ok) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
body.message ?? body.error ?? `WorkOS application fetch failed: HTTP ${response.status}`,
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
return body.connect_application ?? body
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function normalizeIssuer(value: string): string {
|
|
875
|
+
return value.replace(/\/+$/, '')
|
|
876
|
+
}
|