@synthigy/sdk 0.1.2 → 0.2.1
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/README.md +112 -24
- package/codegen/codegen.mjs +128 -56
- package/codegen/lib.mjs +24 -0
- package/package.json +1 -1
- package/src/browser-login.js +310 -0
- package/src/index.d.ts +182 -5
- package/src/index.js +158 -23
- package/src/login.js +244 -0
package/src/index.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* Handles service-identity token acquisition (client credentials) and
|
|
6
6
|
* user impersonation via `acting_as`.
|
|
7
7
|
*
|
|
8
|
+
* `client.login` covers the OAuth2 authorization-code + PKCE exchange for a
|
|
9
|
+
* server-side (confidential) app; sessions and routing stay yours.
|
|
10
|
+
*
|
|
8
11
|
* Intentionally out of scope — leave to the user's chosen libraries:
|
|
9
|
-
* - OAuth2 authorization-code redirect (framework concern)
|
|
10
12
|
* - ID-token JWT verification (pick `jose`, `jsonwebtoken`, etc.)
|
|
11
13
|
* - HTTP pooling / retry / backoff (wrap `fetch` as you see fit)
|
|
12
14
|
*
|
|
@@ -34,8 +36,18 @@
|
|
|
34
36
|
|
|
35
37
|
import { watch as _watch, watchSchema as _watchSchema, getMultiplexer, Watch, SchemaWatch, WatchError } from './watch.js'
|
|
36
38
|
import { watchQuery as _watchQuery, watchSqlTemplate as _watchSqlTemplate, QueryWatch, SqlTemplateWatch } from './watch-query.js'
|
|
39
|
+
import { createLoginApi, rejectingLoginStore, MemoryLoginStore } from './login.js'
|
|
40
|
+
import { createBrowserLogin as _createBrowserLogin, silentCallback } from './browser-login.js'
|
|
41
|
+
|
|
42
|
+
export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch, MemoryLoginStore, silentCallback }
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Browser login as a public client (PKCE, silent renew, tokens in memory).
|
|
46
|
+
* Pass `login.tokenProvider()` to `createClient({ tokenProvider })`.
|
|
47
|
+
*/
|
|
48
|
+
export function createBrowserLogin(config) {
|
|
49
|
+
return _createBrowserLogin(config, { SynthigyError })
|
|
50
|
+
}
|
|
39
51
|
|
|
40
52
|
// ============================================================================
|
|
41
53
|
// Operators — helper functions for building where clauses
|
|
@@ -403,10 +415,7 @@ class TokenManager {
|
|
|
403
415
|
body: params,
|
|
404
416
|
})
|
|
405
417
|
|
|
406
|
-
if (!resp.ok)
|
|
407
|
-
const text = await resp.text()
|
|
408
|
-
throw new Error(`Token request failed (${resp.status}): ${text}`)
|
|
409
|
-
}
|
|
418
|
+
if (!resp.ok) throw _tokenRefusal(resp.status, await resp.text())
|
|
410
419
|
|
|
411
420
|
const data = await resp.json()
|
|
412
421
|
this._tokens.set(key, {
|
|
@@ -422,9 +431,30 @@ class TokenManager {
|
|
|
422
431
|
}
|
|
423
432
|
}
|
|
424
433
|
|
|
434
|
+
/** RFC 6749 §5.2 / RFC 8707 refusals with their own code; any other refusal is UNAUTHORIZED. */
|
|
435
|
+
const REFUSAL_CODES = {
|
|
436
|
+
invalid_client: 'INVALID_CLIENT',
|
|
437
|
+
invalid_scope: 'INVALID_SCOPE',
|
|
438
|
+
invalid_target: 'INVALID_AUDIENCE',
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _tokenRefusal(status, text) {
|
|
442
|
+
let oauth = null
|
|
443
|
+
try { oauth = JSON.parse(text) } catch {}
|
|
444
|
+
if (typeof oauth?.error !== 'string' || oauth.error === '') {
|
|
445
|
+
return new SynthigyError(`Token request failed (${status}): ${text}`, 'UNAUTHORIZED', undefined, { status })
|
|
446
|
+
}
|
|
447
|
+
const code = Object.hasOwn(REFUSAL_CODES, oauth.error) ? REFUSAL_CODES[oauth.error] : 'UNAUTHORIZED'
|
|
448
|
+
const desc = typeof oauth.error_description === 'string' && oauth.error_description !== ''
|
|
449
|
+
? ` — ${oauth.error_description}` : ''
|
|
450
|
+
return new SynthigyError(`Token request refused (${status}): ${oauth.error}${desc}`, code, oauth, {
|
|
451
|
+
status,
|
|
452
|
+
hint: code === 'INVALID_CLIENT' ? 'check the client id and secret, and that the client is active' : undefined,
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
425
456
|
// ============================================================================
|
|
426
|
-
// Supervised stdio — SYNTHIGY_SUPERVISED=1
|
|
427
|
-
// docs/plans/PLAN-EXEC-IDENTITY.md steps 2-4. The SDK never mints locally
|
|
457
|
+
// Supervised stdio — SYNTHIGY_SUPERVISED=1. The SDK never mints locally
|
|
428
458
|
// under this mode: the CLI/commander is the platform's stdio owner, so a
|
|
429
459
|
// token is asked for over JSON-RPC on the process's OWN stdio (supervise
|
|
430
460
|
// grammar) instead. {token, expires_in} is deliberately byte-compatible
|
|
@@ -520,6 +550,7 @@ class SupervisedIO {
|
|
|
520
550
|
_ask(method, params, timeoutMs) {
|
|
521
551
|
this._ensureReader()
|
|
522
552
|
this._ensureWriteGuard()
|
|
553
|
+
process.stdin.ref?.()
|
|
523
554
|
const id = ++this._nextId
|
|
524
555
|
return new Promise((resolve) => {
|
|
525
556
|
let settled = false
|
|
@@ -529,6 +560,8 @@ class SupervisedIO {
|
|
|
529
560
|
settled = true
|
|
530
561
|
clearTimeout(timer)
|
|
531
562
|
this._pending.delete(id)
|
|
563
|
+
// a ref'd stdin keeps the event loop alive — the process would never exit
|
|
564
|
+
if (this._pending.size === 0) process.stdin.unref?.()
|
|
532
565
|
resolve(v)
|
|
533
566
|
}
|
|
534
567
|
timer = setTimeout(() => deliver(null), timeoutMs)
|
|
@@ -592,8 +625,7 @@ function _getSupervisedIO() {
|
|
|
592
625
|
/**
|
|
593
626
|
* Token source for SYNTHIGY_SUPERVISED=1 — asks `auth.token` over the
|
|
594
627
|
* process's own stdio instead of minting locally (the CLI/commander is
|
|
595
|
-
* the platform's stdio owner; this SDK never mints
|
|
596
|
-
* PLAN-EXEC-IDENTITY step 2). Same public shape as TokenManager
|
|
628
|
+
* the platform's stdio owner; this SDK never mints). Same public shape as TokenManager
|
|
597
629
|
* (getToken/clear) so call sites don't care which source is installed —
|
|
598
630
|
* a thin handle onto the real, process-wide SupervisedIO singleton
|
|
599
631
|
* (cache and all), so multiple clients in one process share one cache
|
|
@@ -609,7 +641,7 @@ class SupervisedTokenSource {
|
|
|
609
641
|
}
|
|
610
642
|
}
|
|
611
643
|
|
|
612
|
-
/** The teaching throw
|
|
644
|
+
/** The teaching throw: the error IS the UX, no flag, no silent anonymous fallback. */
|
|
613
645
|
function _noTokenError() {
|
|
614
646
|
return new SynthigyError(
|
|
615
647
|
'no Synthigy token: set token, or clientId + clientSecret, or set ' +
|
|
@@ -647,12 +679,32 @@ const ERROR_CATEGORIES = {
|
|
|
647
679
|
PROVISION_FORBIDDEN: 'auth',
|
|
648
680
|
CLAIM_INVALID: 'auth',
|
|
649
681
|
NO_TOKEN: 'auth', // client-side: no token source configured — retrying can't fix it
|
|
682
|
+
NO_ENDPOINT: 'validation', // client-side: no endpoint configured — retrying can't fix it
|
|
683
|
+
NO_LOGIN_STORE: 'auth', // client-side: no login store registered — retrying can't fix it
|
|
684
|
+
LOGIN_NONCE_MISMATCH: 'auth',
|
|
685
|
+
LOGIN_EXCHANGE_FAILED: 'auth',
|
|
686
|
+
SILENT_TIMEOUT: 'auth',
|
|
687
|
+
USERINFO_SUB_MISMATCH: 'auth',
|
|
688
|
+
INVALID_CLIENT: 'auth',
|
|
689
|
+
INVALID_SCOPE: 'auth',
|
|
690
|
+
INVALID_AUDIENCE: 'auth',
|
|
691
|
+
// OAuth error codes the IdP redirects back with (RFC 6749 4.1.2.1, OIDC 3.1.2.6)
|
|
692
|
+
access_denied: 'auth',
|
|
693
|
+
login_required: 'auth',
|
|
694
|
+
consent_required: 'auth',
|
|
695
|
+
interaction_required: 'auth',
|
|
650
696
|
// iam
|
|
651
697
|
FORBIDDEN: 'iam',
|
|
652
698
|
FORBIDDEN_OP: 'iam',
|
|
653
699
|
ENTITY_FORBIDDEN: 'iam',
|
|
654
700
|
ENTITY_NOT_READABLE: 'iam',
|
|
655
701
|
RELATION_NOT_READABLE: 'iam',
|
|
702
|
+
RELATION_FORBIDDEN: 'iam',
|
|
703
|
+
ATTRIBUTE_FORBIDDEN: 'iam',
|
|
704
|
+
ROW_FORBIDDEN: 'iam',
|
|
705
|
+
CREATE_FORBIDDEN: 'iam',
|
|
706
|
+
DELETE_FORBIDDEN: 'iam',
|
|
707
|
+
SLOT_OCCUPIED: 'iam',
|
|
656
708
|
// validation
|
|
657
709
|
INVALID_BODY: 'validation',
|
|
658
710
|
NO_OPERATIONS: 'validation',
|
|
@@ -681,7 +733,13 @@ const ERROR_CATEGORIES = {
|
|
|
681
733
|
PARAM_MISSING: 'validation',
|
|
682
734
|
PARAM_TYPE_MISMATCH: 'validation',
|
|
683
735
|
NOT_CONNECTED: 'validation', // client-side guard — retrying without connecting can't fix it
|
|
736
|
+
LOGIN_STATE_UNKNOWN: 'validation',
|
|
737
|
+
LOGIN_REQUIRES_CONFIDENTIAL_CLIENT: 'validation',
|
|
684
738
|
XID_REQUIRED: 'validation',
|
|
739
|
+
NAMESPACE_REQUIRED: 'validation',
|
|
740
|
+
DUPLICATE_OPERATION: 'validation',
|
|
741
|
+
BATCH_MEMBER_UNRESOLVED: 'validation',
|
|
742
|
+
CONFIG_ERROR: 'validation',
|
|
685
743
|
CLAIM_METHOD_NOT_ALLOWED: 'validation',
|
|
686
744
|
PASSWORD_TOO_WEAK: 'validation',
|
|
687
745
|
RETURN_URL_NOT_REGISTERED: 'validation',
|
|
@@ -703,11 +761,14 @@ const ERROR_CATEGORIES = {
|
|
|
703
761
|
INTERNAL_ERROR: 'internal',
|
|
704
762
|
OPERATION_ERROR: 'internal',
|
|
705
763
|
HTTP_ERROR: 'internal',
|
|
764
|
+
OPERATION_FAILED: 'internal',
|
|
765
|
+
USERINFO_FAILED: 'internal',
|
|
706
766
|
}
|
|
707
767
|
|
|
708
768
|
/**
|
|
709
769
|
* Codes whose category is automatically retryable. Any other code is
|
|
710
|
-
* one-shot: the caller must fix something before retrying.
|
|
770
|
+
* one-shot: the caller must fix something before retrying. A code missing
|
|
771
|
+
* from ERROR_CATEGORIES is retryable only when the server marks it so.
|
|
711
772
|
*/
|
|
712
773
|
const RETRYABLE_CATEGORIES = new Set(['network', 'rate_limit', 'internal'])
|
|
713
774
|
|
|
@@ -748,7 +809,9 @@ export class SynthigyError extends Error {
|
|
|
748
809
|
this.name = 'SynthigyError'
|
|
749
810
|
this.code = code
|
|
750
811
|
this.category = ERROR_CATEGORIES[code] ?? 'internal'
|
|
751
|
-
this.retryable =
|
|
812
|
+
this.retryable = typeof extras?.retryable === 'boolean'
|
|
813
|
+
? extras.retryable
|
|
814
|
+
: Object.hasOwn(ERROR_CATEGORIES, code) && RETRYABLE_CATEGORIES.has(this.category)
|
|
752
815
|
if (details !== undefined) this.details = details
|
|
753
816
|
if (extras) {
|
|
754
817
|
if (extras.hint !== undefined) this.hint = extras.hint
|
|
@@ -804,6 +867,7 @@ function _errorFromServer(err, status, requestId) {
|
|
|
804
867
|
start: err.start,
|
|
805
868
|
end: err.end,
|
|
806
869
|
diagnostics: err.diagnostics,
|
|
870
|
+
retryable: err.retryable,
|
|
807
871
|
requestId,
|
|
808
872
|
status,
|
|
809
873
|
})
|
|
@@ -831,15 +895,17 @@ async function onboardError(resp) {
|
|
|
831
895
|
class SynthigyClient {
|
|
832
896
|
/**
|
|
833
897
|
* @param {Object} config
|
|
834
|
-
* @param {string} config.endpoint - Synthigy base URL
|
|
898
|
+
* @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
|
|
835
899
|
* @param {string} [config.clientId] - OAuth client ID (for client credentials)
|
|
836
900
|
* @param {string} [config.clientSecret] - OAuth client secret
|
|
837
901
|
* @param {string} [config.token] - Static bearer token (alternative to client credentials)
|
|
902
|
+
* @param {{getToken: (audience?: string) => string|null|Promise<string|null>, clear: () => void}} [config.tokenProvider]
|
|
903
|
+
* - Caller-owned token source; `clear()` is called on a 401 before the one retry
|
|
838
904
|
*
|
|
839
905
|
* On Node, with none of the above, the constructor also falls back to —
|
|
840
906
|
* when SYNTHIGY_SUPERVISED=1 — a supervised-stdio auth.token ask (the
|
|
841
907
|
* pipe beats the env var: it can refresh mid-run), then the
|
|
842
|
-
* SYNTHIGY_TOKEN env var
|
|
908
|
+
* SYNTHIGY_TOKEN env var.
|
|
843
909
|
* With no source at all it throws a SynthigyError whose code is
|
|
844
910
|
* NO_TOKEN and whose message teaches the fix. A browser has neither
|
|
845
911
|
* env vars nor a stdio parent, so it always requires an explicit source.
|
|
@@ -863,17 +929,24 @@ class SynthigyClient {
|
|
|
863
929
|
* fetch: (url, init) => fetch(url, { ...init, dispatcher: agent }),
|
|
864
930
|
* })
|
|
865
931
|
*/
|
|
866
|
-
constructor(config) {
|
|
867
|
-
|
|
932
|
+
constructor(config = {}) {
|
|
933
|
+
const endpoint = config.endpoint
|
|
934
|
+
?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_ENDPOINT : undefined)
|
|
935
|
+
if (!endpoint) {
|
|
936
|
+
throw new SynthigyError(
|
|
937
|
+
'no endpoint — pass config.endpoint, or set SYNTHIGY_ENDPOINT (run under `synthigy exec`)',
|
|
938
|
+
'NO_ENDPOINT')
|
|
939
|
+
}
|
|
940
|
+
this._endpoint = endpoint.replace(/\/$/, '')
|
|
868
941
|
this._dataUrl = `${this._endpoint}/data`
|
|
869
942
|
this._defaultActingAs = config.actingAs ?? null
|
|
870
943
|
this._defaultKeyFormat = config.keyFormat ?? null
|
|
871
944
|
this._timeout = config.timeout ?? null
|
|
872
945
|
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis)
|
|
873
|
-
// Stored so
|
|
874
|
-
// default the ID-token audience to this client's own id.
|
|
946
|
+
// Stored so `client.login` can authenticate the code exchange.
|
|
875
947
|
this._clientId = config.clientId ?? null
|
|
876
948
|
this._clientSecret = config.clientSecret ?? null
|
|
949
|
+
this._loginStore = config.loginStore ?? rejectingLoginStore(SynthigyError)
|
|
877
950
|
// Long-lived contexts (BFFs, Node services) want the upstream SSE pinned
|
|
878
951
|
// open across user-watch churn so the plug session keyed by
|
|
879
952
|
// [sub, client_id] isn't torn down between watches. Replaces the
|
|
@@ -890,7 +963,12 @@ class SynthigyClient {
|
|
|
890
963
|
?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_AUDIENCE : undefined)
|
|
891
964
|
?? PLATFORM_AUDIENCE
|
|
892
965
|
|
|
893
|
-
if (config.
|
|
966
|
+
if (config.tokenProvider) {
|
|
967
|
+
// Caller-owned source ({ getToken(audience), clear() }) — e.g. the
|
|
968
|
+
// browser login. clear() on a 401 is its cue to refresh.
|
|
969
|
+
this._tokenManager = config.tokenProvider
|
|
970
|
+
this._getToken = () => this._tokenManager.getToken(this._defaultAudience)
|
|
971
|
+
} else if (config.token !== undefined && config.token !== null) {
|
|
894
972
|
// Static token mode — empty string allowed for unauth / dev servers.
|
|
895
973
|
this._tokenManager = null
|
|
896
974
|
this._getToken = async () => config.token
|
|
@@ -1774,6 +1852,55 @@ class SynthigyClient {
|
|
|
1774
1852
|
return json.diagnostics ?? []
|
|
1775
1853
|
}
|
|
1776
1854
|
|
|
1855
|
+
/**
|
|
1856
|
+
* Compile an XSQL source to the wire operation the engine would execute,
|
|
1857
|
+
* via `POST /compile` — nothing runs. The compiler is the authority on
|
|
1858
|
+
* the wire format, so this is how you take programmatic control of a
|
|
1859
|
+
* query instead of hand-writing the JSON: edit the returned map and send
|
|
1860
|
+
* it through `batch()`.
|
|
1861
|
+
*
|
|
1862
|
+
* `params` bind exactly as they do on `query()`, so what comes back IS
|
|
1863
|
+
* what the engine receives.
|
|
1864
|
+
*
|
|
1865
|
+
* @param {string} source - XSQL source (bare body or full `@verb` document)
|
|
1866
|
+
* @param {Object} [opts]
|
|
1867
|
+
* @param {Object} [opts.params] - `?name:type` bindings
|
|
1868
|
+
* @param {'search'|'get'|'slice'|'purge'} [opts.op='search'] - verb for a bare body
|
|
1869
|
+
* @returns {Promise<Object>} the wire op `{op, entity, args, selections}`
|
|
1870
|
+
*
|
|
1871
|
+
* @example
|
|
1872
|
+
* ```js
|
|
1873
|
+
* const op = await client.compile('movie (limit ?n:int=5)\n title\n',
|
|
1874
|
+
* { params: { n: 2 } })
|
|
1875
|
+
* op.args._limit = 10
|
|
1876
|
+
* const [rows] = await client.batch([op])
|
|
1877
|
+
* ```
|
|
1878
|
+
*/
|
|
1879
|
+
async compile(source, opts) {
|
|
1880
|
+
const op = { op: 'xsql', xsql: xsqlDocument(source, opts?.op ?? 'search') }
|
|
1881
|
+
if (opts?.params) op.params = opts.params
|
|
1882
|
+
const resp = await this._fetchAuth(`${this._endpoint}/compile`, {
|
|
1883
|
+
method: 'POST',
|
|
1884
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1885
|
+
body: JSON.stringify({ operations: [op] }),
|
|
1886
|
+
})
|
|
1887
|
+
const json = await resp.json().catch(() => ({}))
|
|
1888
|
+
if (!resp.ok) {
|
|
1889
|
+
throw new SynthigyError(
|
|
1890
|
+
json.error?.message || `Compile request failed (${resp.status})`,
|
|
1891
|
+
json.error?.code || 'HTTP_ERROR'
|
|
1892
|
+
)
|
|
1893
|
+
}
|
|
1894
|
+
const [result] = json.results ?? []
|
|
1895
|
+
if (!result?.ok) {
|
|
1896
|
+
throw new SynthigyError(
|
|
1897
|
+
result?.error?.message || 'Compile failed',
|
|
1898
|
+
result?.error?.code || 'XSQL_PARSE_ERROR'
|
|
1899
|
+
)
|
|
1900
|
+
}
|
|
1901
|
+
return result.operation
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1777
1904
|
/**
|
|
1778
1905
|
* Mint a one-time account-claim link via `POST /oauth/onboard`.
|
|
1779
1906
|
* Confidential client whose principal administers the account — RBAC
|
|
@@ -1961,6 +2088,11 @@ class SynthigyClient {
|
|
|
1961
2088
|
return body.result
|
|
1962
2089
|
}
|
|
1963
2090
|
|
|
2091
|
+
get login() {
|
|
2092
|
+
if (!this._login) this._login = createLoginApi(this, { SynthigyError })
|
|
2093
|
+
return this._login
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1964
2096
|
// Defined as a regular property so the .d.ts namespace `client.history.foo`
|
|
1965
2097
|
// shape works at edit time.
|
|
1966
2098
|
get history() {
|
|
@@ -2426,6 +2558,7 @@ export const op = {
|
|
|
2426
2558
|
searchTree: (entity, on, args, sel) => ({ op: 'search-tree', entity, on, args, selections: normalizeSelection(sel) }),
|
|
2427
2559
|
getTree: (entity, root, on, sel) => ({ op: 'get-tree', entity, root, on, selections: normalizeSelection(sel) }),
|
|
2428
2560
|
sqlTemplate: (template, params) => ({ op: 'sql-template', template, params: params || [] }),
|
|
2561
|
+
query: (xsql, params, verb = 'search') => ({ op: 'xsql', xsql: xsqlDocument(xsql, verb), ...(params !== undefined && { params }) }),
|
|
2429
2562
|
deployedModel: () => ({ op: 'deployed-model' }),
|
|
2430
2563
|
runtimeModel: () => ({ op: 'runtime-model' }),
|
|
2431
2564
|
deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
|
|
@@ -2440,7 +2573,7 @@ export const op = {
|
|
|
2440
2573
|
* Create a Synthigy client.
|
|
2441
2574
|
*
|
|
2442
2575
|
* @param {Object} config
|
|
2443
|
-
* @param {string} config.endpoint - Synthigy base URL
|
|
2576
|
+
* @param {string} [config.endpoint] - Synthigy base URL (default $SYNTHIGY_ENDPOINT)
|
|
2444
2577
|
* @param {string} [config.clientId] - OAuth client ID
|
|
2445
2578
|
* @param {string} [config.clientSecret] - OAuth client secret
|
|
2446
2579
|
* @param {string} [config.token] - Static bearer token (alternative to client credentials)
|
|
@@ -2467,7 +2600,7 @@ export const op = {
|
|
|
2467
2600
|
* })
|
|
2468
2601
|
* ```
|
|
2469
2602
|
*/
|
|
2470
|
-
export function createClient(config) {
|
|
2603
|
+
export function createClient(config = {}) {
|
|
2471
2604
|
return new SynthigyClient(config)
|
|
2472
2605
|
}
|
|
2473
2606
|
|
|
@@ -2496,7 +2629,7 @@ let _defaultClient = null
|
|
|
2496
2629
|
* @param {Object} config - same shape as {@link createClient}
|
|
2497
2630
|
* @returns {SynthigyClient} the installed client (for introspection)
|
|
2498
2631
|
*/
|
|
2499
|
-
export function connect(config) {
|
|
2632
|
+
export function connect(config = {}) {
|
|
2500
2633
|
const prev = _defaultClient
|
|
2501
2634
|
_defaultClient = new SynthigyClient(config)
|
|
2502
2635
|
if (prev) prev.close() // destroy the old one, server-restart style
|
|
@@ -2553,6 +2686,7 @@ export { _delete as delete }
|
|
|
2553
2686
|
// Schema / introspection
|
|
2554
2687
|
export const schema = (...a) => _dflt().schema(...a)
|
|
2555
2688
|
export const lint = (...a) => _dflt().lint(...a)
|
|
2689
|
+
export const compile = (...a) => _dflt().compile(...a)
|
|
2556
2690
|
export const onboard = (...a) => _dflt().onboard(...a)
|
|
2557
2691
|
export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
|
|
2558
2692
|
export const deploy = (...a) => _dflt().deploy(...a)
|
|
@@ -2561,6 +2695,7 @@ export const deployedModel = (...a) => _dflt().deployedModel(...a)
|
|
|
2561
2695
|
export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
|
|
2562
2696
|
export const token = (...a) => _dflt().token(...a)
|
|
2563
2697
|
export const history = () => _dflt().history
|
|
2698
|
+
export const login = () => _dflt().login
|
|
2564
2699
|
|
|
2565
2700
|
// Streaming + live watches
|
|
2566
2701
|
export const listen = (...a) => _dflt().listen(...a)
|
package/src/login.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC authorization-code + PKCE — the protocol half of a browser login.
|
|
3
|
+
*
|
|
4
|
+
* What this owns: the PKCE pair, the authorize URL, the code exchange, and
|
|
5
|
+
* reading the subject out of the id_token. What it deliberately does NOT own:
|
|
6
|
+
* sessions, cookies, redirects, routing. Those are your app's, and they are
|
|
7
|
+
* where a framework dependency would come from. The one piece of state the
|
|
8
|
+
* protocol itself needs — the in-flight login, from /login until the callback
|
|
9
|
+
* comes back — you supply as a `loginStore`.
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies and no Node built-ins: WebCrypto, btoa/atob, URL.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const PKCE_VERIFIER_BYTES = 32
|
|
15
|
+
const STATE_BYTES = 24
|
|
16
|
+
// must exceed the server's 8-minute login window, or slow logins fail LOGIN_STATE_UNKNOWN
|
|
17
|
+
const LOGIN_TTL_MS = 10 * 60_000
|
|
18
|
+
|
|
19
|
+
function base64urlEncode(bytes) {
|
|
20
|
+
let binary = ''
|
|
21
|
+
for (const b of bytes) binary += String.fromCharCode(b)
|
|
22
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function base64urlDecode(text) {
|
|
26
|
+
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
|
|
27
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
|
28
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
29
|
+
return new TextDecoder().decode(bytes)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function randomToken(bytes) {
|
|
33
|
+
return base64urlEncode(globalThis.crypto.getRandomValues(new Uint8Array(bytes)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fresh PKCE verifier and its S256 challenge (RFC 7636). */
|
|
37
|
+
export async function pkce() {
|
|
38
|
+
const codeVerifier = randomToken(PKCE_VERIFIER_BYTES)
|
|
39
|
+
const digest = await globalThis.crypto.subtle.digest(
|
|
40
|
+
'SHA-256', new TextEncoder().encode(codeVerifier))
|
|
41
|
+
return { codeVerifier, codeChallenge: base64urlEncode(new Uint8Array(digest)) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read a JWT's payload without verifying its signature.
|
|
46
|
+
*
|
|
47
|
+
* Safe here and nowhere else: this token arrived in the body of our own
|
|
48
|
+
* client-authenticated POST to the token endpoint, over TLS, so its origin is
|
|
49
|
+
* already established by the channel. A signature check would be guarding
|
|
50
|
+
* against an attacker who can already answer as the IdP. Never use this on a
|
|
51
|
+
* token that reached you any other way — verifying those stays out of the
|
|
52
|
+
* SDK's scope, and `jose` is the library for it.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeJwtPayload(jwt) {
|
|
55
|
+
const parts = String(jwt ?? '').split('.')
|
|
56
|
+
if (parts.length < 2) return null
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(base64urlDecode(parts[1]))
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* In-flight logins in a Map — SINGLE PROCESS ONLY.
|
|
66
|
+
*
|
|
67
|
+
* Every instance behind a load balancer gets its own Map, so a callback that
|
|
68
|
+
* lands on a different instance than the one that started the login fails with
|
|
69
|
+
* LOGIN_STATE_UNKNOWN. Real deployments back the store with whatever already
|
|
70
|
+
* holds their sessions (Redis, Postgres, a signed cookie).
|
|
71
|
+
*/
|
|
72
|
+
export class MemoryLoginStore {
|
|
73
|
+
constructor({ ttlMs = LOGIN_TTL_MS } = {}) {
|
|
74
|
+
this._ttlMs = ttlMs
|
|
75
|
+
this._pending = new Map()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
put(state, login) {
|
|
79
|
+
// Swept on write rather than on a timer: a timer would keep a Node
|
|
80
|
+
// process alive and would have to be torn down by someone.
|
|
81
|
+
const cutoff = Date.now() - this._ttlMs
|
|
82
|
+
for (const [key, value] of this._pending) {
|
|
83
|
+
if (value.createdAt <= cutoff) this._pending.delete(key)
|
|
84
|
+
}
|
|
85
|
+
this._pending.set(state, login)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
take(state) {
|
|
89
|
+
const login = this._pending.get(state)
|
|
90
|
+
if (!login) return null
|
|
91
|
+
this._pending.delete(state)
|
|
92
|
+
return login.createdAt <= Date.now() - this._ttlMs ? null : login
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function noLoginStoreError(SynthigyError) {
|
|
97
|
+
return new SynthigyError(
|
|
98
|
+
'no login store: pass createClient({ loginStore }) — an object with ' +
|
|
99
|
+
'put(state, login) and take(state), backed by whatever already holds ' +
|
|
100
|
+
'your sessions. For a single-process dev server, import ' +
|
|
101
|
+
'{ MemoryLoginStore } and pass new MemoryLoginStore().',
|
|
102
|
+
'NO_LOGIN_STORE')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A store that refuses rather than silently keeping logins in one process. */
|
|
106
|
+
export function rejectingLoginStore(SynthigyError) {
|
|
107
|
+
return {
|
|
108
|
+
put() { throw noLoginStoreError(SynthigyError) },
|
|
109
|
+
take() { throw noLoginStoreError(SynthigyError) },
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The `client.login` namespace: start / complete / cancel.
|
|
115
|
+
*
|
|
116
|
+
* Takes the client rather than importing it, so this module never depends on
|
|
117
|
+
* index.js and the two can't cycle.
|
|
118
|
+
*/
|
|
119
|
+
export function createLoginApi(client, { SynthigyError }) {
|
|
120
|
+
const requireConfidential = () => {
|
|
121
|
+
if (!client._clientId || !client._clientSecret) {
|
|
122
|
+
throw new SynthigyError(
|
|
123
|
+
'login needs clientId + clientSecret: the code exchange authenticates ' +
|
|
124
|
+
'this process as a confidential client. A browser or native app is a ' +
|
|
125
|
+
'public client and drives the same flow with a PKCE library of its ' +
|
|
126
|
+
'own — see docs/AUTH.md.',
|
|
127
|
+
'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// the store's own expiry is not trusted: one that never expires would accept a login of any age
|
|
132
|
+
const takePending = async (state) => {
|
|
133
|
+
const pending = state ? await client._loginStore.take(state) : null
|
|
134
|
+
return pending && pending.createdAt > Date.now() - LOGIN_TTL_MS ? pending : null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
/**
|
|
139
|
+
* Begin a login: returns the URL to redirect the browser to, and stashes
|
|
140
|
+
* the PKCE verifier under a fresh `state` in the login store.
|
|
141
|
+
*/
|
|
142
|
+
async start({ redirectUri, returnTo = '/', scope = 'openid', publicEndpoint } = {}) {
|
|
143
|
+
requireConfidential()
|
|
144
|
+
if (!redirectUri) {
|
|
145
|
+
throw new SynthigyError(
|
|
146
|
+
'login.start needs redirectUri — the URL on YOUR server the IdP ' +
|
|
147
|
+
'sends the browser back to, registered on this OAuth client.',
|
|
148
|
+
'LOGIN_REQUIRES_CONFIDENTIAL_CLIENT')
|
|
149
|
+
}
|
|
150
|
+
const state = randomToken(STATE_BYTES)
|
|
151
|
+
const nonce = randomToken(STATE_BYTES)
|
|
152
|
+
const { codeVerifier, codeChallenge } = await pkce()
|
|
153
|
+
// Stored BEFORE the URL is handed out: a browser that follows the
|
|
154
|
+
// redirect faster than the store commits would come back to nothing.
|
|
155
|
+
await client._loginStore.put(state, { codeVerifier, nonce, returnTo, createdAt: Date.now() })
|
|
156
|
+
|
|
157
|
+
const url = new URL(`${publicEndpoint ?? client._endpoint}/oauth/authorize`)
|
|
158
|
+
url.searchParams.set('response_type', 'code')
|
|
159
|
+
url.searchParams.set('client_id', client._clientId)
|
|
160
|
+
url.searchParams.set('redirect_uri', redirectUri)
|
|
161
|
+
url.searchParams.set('scope', scope)
|
|
162
|
+
url.searchParams.set('state', state)
|
|
163
|
+
url.searchParams.set('code_challenge', codeChallenge)
|
|
164
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
165
|
+
url.searchParams.set('nonce', nonce)
|
|
166
|
+
return { url: url.toString() }
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Exchange the callback's code for tokens and the user behind them.
|
|
171
|
+
*
|
|
172
|
+
* `user.xid` comes straight out of the id_token — the engine puts it on
|
|
173
|
+
* every token it signs — so this costs no /data round-trip.
|
|
174
|
+
*/
|
|
175
|
+
async complete({ code, state, redirectUri } = {}) {
|
|
176
|
+
requireConfidential()
|
|
177
|
+
const pending = await takePending(state)
|
|
178
|
+
if (!pending) {
|
|
179
|
+
throw new SynthigyError(
|
|
180
|
+
'unknown or expired login state: the store never saw it, it was ' +
|
|
181
|
+
'already consumed, or it timed out. A callback landing on a ' +
|
|
182
|
+
'different instance than the one that served /login does this.',
|
|
183
|
+
'LOGIN_STATE_UNKNOWN')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const params = new URLSearchParams({
|
|
187
|
+
grant_type: 'authorization_code',
|
|
188
|
+
code,
|
|
189
|
+
redirect_uri: redirectUri,
|
|
190
|
+
client_id: client._clientId,
|
|
191
|
+
client_secret: client._clientSecret,
|
|
192
|
+
code_verifier: pending.codeVerifier,
|
|
193
|
+
})
|
|
194
|
+
const resp = await client._fetch(`${client._endpoint}/oauth/token`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
197
|
+
body: params,
|
|
198
|
+
})
|
|
199
|
+
if (!resp.ok) {
|
|
200
|
+
const text = await resp.text().catch(() => '')
|
|
201
|
+
throw new SynthigyError(
|
|
202
|
+
`authorization-code exchange failed (${resp.status})`,
|
|
203
|
+
'LOGIN_EXCHANGE_FAILED', text, { status: resp.status })
|
|
204
|
+
}
|
|
205
|
+
const tokens = await resp.json()
|
|
206
|
+
const claims = decodeJwtPayload(tokens.id_token)
|
|
207
|
+
if (!claims) {
|
|
208
|
+
throw new SynthigyError(
|
|
209
|
+
'token response carried no readable id_token — request the openid ' +
|
|
210
|
+
'scope, which is what names the user.',
|
|
211
|
+
'LOGIN_EXCHANGE_FAILED')
|
|
212
|
+
}
|
|
213
|
+
if (claims.nonce !== pending.nonce) {
|
|
214
|
+
throw new SynthigyError(
|
|
215
|
+
'id_token nonce does not match the one this login started with',
|
|
216
|
+
'LOGIN_NONCE_MISMATCH')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
user: {
|
|
221
|
+
xid: claims.xid ?? null,
|
|
222
|
+
name: claims.sub ?? null,
|
|
223
|
+
// Granted scopes live in the token response; the id_token carries
|
|
224
|
+
// them only when the IdP chose to mirror them.
|
|
225
|
+
scopes: String(tokens.scope ?? claims.scope ?? '').split(' ').filter(Boolean),
|
|
226
|
+
},
|
|
227
|
+
tokens,
|
|
228
|
+
returnTo: pending.returnTo,
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Discard an in-flight login and recover where the visitor was headed.
|
|
234
|
+
*
|
|
235
|
+
* Cancelling on the IdP's login page is a normal outcome (RFC 6749
|
|
236
|
+
* §4.1.2.1 sends `error` back with no code), so an unknown state is null
|
|
237
|
+
* here rather than a throw.
|
|
238
|
+
*/
|
|
239
|
+
async cancel(state) {
|
|
240
|
+
const pending = await takePending(state)
|
|
241
|
+
return pending ? { returnTo: pending.returnTo } : null
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
}
|