@venn-lang/auth 0.1.0
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/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +343 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/apikey.ts +21 -0
- package/src/actions/basic.ts +23 -0
- package/src/actions/bearer.ts +11 -0
- package/src/actions/hmac.ts +20 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/jwt.ts +24 -0
- package/src/actions/oauth2.ts +34 -0
- package/src/actions/totp.ts +33 -0
- package/src/clients/fake-client.ts +17 -0
- package/src/clients/index.ts +2 -0
- package/src/clients/real-client.ts +19 -0
- package/src/crypto/bytes.ts +21 -0
- package/src/crypto/hmac.ts +46 -0
- package/src/crypto/index.ts +4 -0
- package/src/crypto/jwt.ts +27 -0
- package/src/crypto/totp.ts +46 -0
- package/src/index.ts +9 -0
- package/src/plugin.ts +22 -0
- package/src/port/auth-client.port.ts +16 -0
- package/src/port/auth-client.types.ts +24 -0
- package/src/port/index.ts +2 -0
- package/src/types/index.ts +2 -0
- package/src/types/token.ts +8 -0
- package/src/types/type-defs.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Borges
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @venn-lang/auth
|
|
2
|
+
|
|
3
|
+
> The `auth` namespace: build the header a request needs, or fetch a token for it.
|
|
4
|
+
|
|
5
|
+
Six of the seven verbs are pure computation, headers, signatures and codes worked out from what you
|
|
6
|
+
hand them. The seventh, `auth.oauth2`, is the one that talks to a token endpoint, and it goes
|
|
7
|
+
through the `AuthClient` port so the exchange is injected rather than hard-wired. Real cryptography
|
|
8
|
+
uses the global Web Crypto (`crypto.subtle`), so nothing here imports `node:*`.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Nothing to install. `@venn-lang/auth` is part of the standard library, and the CLI and the language
|
|
13
|
+
server both load [`@venn-lang/stdlib`](../stdlib), which lists every stdlib plugin. A file reaches the
|
|
14
|
+
namespace with one line.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
use "venn/auth"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
module demo.auth
|
|
24
|
+
|
|
25
|
+
use "venn/auth"
|
|
26
|
+
use "venn/assert"
|
|
27
|
+
|
|
28
|
+
flow "Signed in" {
|
|
29
|
+
step "a bearer header carries the token" {
|
|
30
|
+
const header = auth.bearer "tok123"
|
|
31
|
+
expect header.Authorization == "Bearer tok123"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
step "a service account gets a token" {
|
|
35
|
+
const token = auth.oauth2 "svc-account" { grant: "client_credentials", scope: "orders:write" }
|
|
36
|
+
expect token.expires_in > 0
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Verbs
|
|
42
|
+
|
|
43
|
+
| Verb | Call | Result |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `auth.bearer` | `auth.bearer "tok123"` | `auth.Headers` |
|
|
46
|
+
| `auth.basic` | `auth.basic "alice" "s3cret"` | `auth.Headers` |
|
|
47
|
+
| `auth.apikey` | `auth.apikey "KEY" { header: "X-Token" }` | `auth.Headers` |
|
|
48
|
+
| `auth.hmac` | `auth.hmac "s3cret" "payload" { algo: "sha512" }` | `string`, lowercase hex |
|
|
49
|
+
| `auth.totp` | `auth.totp "seed" { at: 0, period: 30, digits: 6 }` | `string` |
|
|
50
|
+
| `auth.jwt` | `auth.jwt { payload: { sub: "42" }, secret: "k" }` | `string` |
|
|
51
|
+
| `auth.oauth2` | `auth.oauth2 "svc-account" { grant: "client_credentials" }` | `auth.Token` |
|
|
52
|
+
|
|
53
|
+
**`bearer`** returns `{ Authorization: "Bearer <token>" }`.
|
|
54
|
+
|
|
55
|
+
**`basic`** base64-encodes `user:pass` into `{ Authorization: "Basic <…>" }`.
|
|
56
|
+
|
|
57
|
+
**`apikey`** puts the key under a header of your choosing. The `header` option defaults to
|
|
58
|
+
`X-API-Key`, so `auth.apikey "KEY"` gives `{ "X-API-Key": "KEY" }`.
|
|
59
|
+
|
|
60
|
+
**`hmac`** takes the secret first and the payload second, so `auth.hmac "s3cret" "payload"` keys the
|
|
61
|
+
HMAC with `s3cret`. The `algo` option accepts `sha1`, `sha256`, `sha384` and `sha512`, case and
|
|
62
|
+
dashes ignored, and anything it does not recognise falls back to SHA-256.
|
|
63
|
+
|
|
64
|
+
**`totp`** computes an RFC 6238 code and is deterministic for a fixed `at`, which makes it usable in
|
|
65
|
+
a test. `at` is the time to compute at and `period` the step, both in seconds; `digits` defaults
|
|
66
|
+
to 6 and the result is zero-padded, which is why it is a string and not a number.
|
|
67
|
+
|
|
68
|
+
**`jwt`** takes nothing positionally. `payload` and `secret` are required options, `header` is
|
|
69
|
+
optional and is merged over the default `{ alg: "HS256", typ: "JWT" }`. The signature is HS256.
|
|
70
|
+
|
|
71
|
+
**`oauth2`** takes the principal positionally and `grant`, `tokenUrl`, `scope` and `refresh` as
|
|
72
|
+
options. It is the only verb here that reaches a port.
|
|
73
|
+
|
|
74
|
+
## The types it publishes
|
|
75
|
+
|
|
76
|
+
| Type | Shape |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `auth.Headers` | `map<string>`. A map rather than a record, because `auth.apikey` names its own header and the key is only known at the call site. |
|
|
79
|
+
| `auth.Token` | `{ access_token, token_type, expires_in }`, the OAuth2 wire shape. |
|
|
80
|
+
|
|
81
|
+
## The AuthClient port
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| id | `venn.port.auth-client` |
|
|
86
|
+
| version | 1 |
|
|
87
|
+
| requires | `net` |
|
|
88
|
+
| methods | `token` |
|
|
89
|
+
|
|
90
|
+
Two implementations ship together, which is what makes this a port rather than a module with a good
|
|
91
|
+
interface:
|
|
92
|
+
|
|
93
|
+
- `createFakeAuthClient()` returns a canned token derived from the principal, with no network. This
|
|
94
|
+
is the one [`@venn-lang/stdlib`](../stdlib) binds, so `auth.oauth2` resolves offline. Pass
|
|
95
|
+
`{ token: { expires_in: 60 } }` to override any field of what it hands back.
|
|
96
|
+
- `createRealAuthClient()` is a stub. Every call throws a `VennError` with code `VN8090`, because
|
|
97
|
+
this repository is the language and ships no live token exchange.
|
|
98
|
+
|
|
99
|
+
Both run the same conformance suite, `authClientConformance` in `src/clients/auth-client.suite.ts`:
|
|
100
|
+
a `token` resolves with a string `access_token`, a string `token_type` and a numeric `expires_in`.
|
|
101
|
+
|
|
102
|
+
The plugin as a whole declares `requires: ["net"]`, so a host without the `net` capability is
|
|
103
|
+
refused with a legible diagnostic before the run starts, rather than failing somewhere inside
|
|
104
|
+
`auth.oauth2`.
|
|
105
|
+
|
|
106
|
+
## API
|
|
107
|
+
|
|
108
|
+
| Export | What it is |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `authPlugin` | The plugin definition: namespace `auth`, `requires: ["net"]`. Also the default export. |
|
|
111
|
+
| `authActions` | The seven action definitions: `bearer`, `basic`, `apikey`, `hmac`, `totp`, `jwt`, `oauth2`. |
|
|
112
|
+
| `AuthClientPort` | The port descriptor. |
|
|
113
|
+
| `AuthClient` | The port interface: `token(request)`. |
|
|
114
|
+
| `OAuthTokenRequest` | What the port is asked: `principal`, and the optional `grant`, `tokenUrl`, `scope`, `refresh`. |
|
|
115
|
+
| `OAuthToken` | What it answers: `{ access_token, token_type, expires_in }`. |
|
|
116
|
+
| `createFakeAuthClient({ token? })` | The deterministic double. |
|
|
117
|
+
| `createRealAuthClient()` | The real client, stubbed to throw `VN8090`. |
|
|
118
|
+
| `Token` | The Zod schema registered as the plugin's nominal `Token` type. |
|
|
119
|
+
| `authTypeDefs` | The `TypeSpec`s for `auth.Headers` and `auth.Token`, which the checker and the LSP read. |
|
|
120
|
+
|
|
121
|
+
Binding a different client means one entry in the runner's port list:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { AuthClientPort, createFakeAuthClient } from "@venn-lang/auth";
|
|
125
|
+
import { createRunner } from "@venn-lang/runtime";
|
|
126
|
+
|
|
127
|
+
const runner = createRunner({
|
|
128
|
+
host,
|
|
129
|
+
plugins,
|
|
130
|
+
sink,
|
|
131
|
+
uri,
|
|
132
|
+
ports: [{ port: AuthClientPort, impl: createFakeAuthClient() }],
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## See also
|
|
137
|
+
|
|
138
|
+
- [`@venn-lang/http`](../std-http) for the requests these headers are attached to.
|
|
139
|
+
- [`@venn-lang/crypto`](../std-crypto) for hashing and encoding outside an auth flow.
|
|
140
|
+
- [`@venn-lang/sdk`](../sdk) for `definePlugin` and `defineAction`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ActionDefinition, PluginDefinition, ZodType } from "@venn-lang/sdk";
|
|
2
|
+
import { TypeSpec } from "@venn-lang/types";
|
|
3
|
+
import { Port } from "@venn-lang/contracts";
|
|
4
|
+
//#region src/actions/index.d.ts
|
|
5
|
+
/** The auth namespace's verbs. Adding one is a single line here. */
|
|
6
|
+
declare const authActions: ActionDefinition[];
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/port/auth-client.types.d.ts
|
|
9
|
+
/** Arguments for {@link AuthClient.token}: which principal, and how to obtain or refresh it. */
|
|
10
|
+
interface OAuthTokenRequest {
|
|
11
|
+
principal: string;
|
|
12
|
+
grant?: string;
|
|
13
|
+
tokenUrl?: string;
|
|
14
|
+
scope?: string;
|
|
15
|
+
refresh?: string;
|
|
16
|
+
}
|
|
17
|
+
/** A token as the endpoint answered with it. Snake case, because that is the wire shape. */
|
|
18
|
+
interface OAuthToken {
|
|
19
|
+
access_token: string;
|
|
20
|
+
token_type: string;
|
|
21
|
+
expires_in: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The contract `auth.oauth2` acquires tokens through.
|
|
25
|
+
*
|
|
26
|
+
* Reached via `ctx.port(AuthClientPort)`, never by calling an endpoint directly.
|
|
27
|
+
*/
|
|
28
|
+
interface AuthClient {
|
|
29
|
+
token(request: OAuthTokenRequest): Promise<OAuthToken>;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/port/auth-client.port.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* The port `auth.oauth2` obtains its tokens through.
|
|
35
|
+
*
|
|
36
|
+
* Bound by the host to `createFakeAuthClient` or `createRealAuthClient`.
|
|
37
|
+
* Requires the `net` capability, so a host without it is refused at load time
|
|
38
|
+
* rather than failing mid-run.
|
|
39
|
+
*/
|
|
40
|
+
declare const AuthClientPort: Port<AuthClient>;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/clients/fake-client.d.ts
|
|
43
|
+
/**
|
|
44
|
+
* The in-process `AuthClient`: a canned token derived from the principal, no network.
|
|
45
|
+
*
|
|
46
|
+
* @param args.token Fields to override on every token it hands back.
|
|
47
|
+
*/
|
|
48
|
+
declare function createFakeAuthClient(args?: {
|
|
49
|
+
token?: Partial<OAuthToken>;
|
|
50
|
+
}): AuthClient;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/clients/real-client.d.ts
|
|
53
|
+
/**
|
|
54
|
+
* The endpoint-backed `AuthClient`. Not implemented in this build.
|
|
55
|
+
*
|
|
56
|
+
* @throws VennError `VN8090` on every call, so a host that binds it fails loudly
|
|
57
|
+
* instead of quietly handing a script a token that was never obtained.
|
|
58
|
+
*/
|
|
59
|
+
declare function createRealAuthClient(): AuthClient;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/plugin.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* The `auth` plugin: header builders, signing helpers and an OAuth2 exchange.
|
|
64
|
+
*
|
|
65
|
+
* Every verb but `auth.oauth2` is pure. That one reaches out through
|
|
66
|
+
* `AuthClientPort`, which is why the whole plugin declares the `net` capability
|
|
67
|
+
* and is refused at load time on a host that cannot offer it.
|
|
68
|
+
*/
|
|
69
|
+
declare const authPlugin: PluginDefinition;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/types/token.d.ts
|
|
72
|
+
/** Runtime schema for the nominal `Token` type, the value `auth.oauth2` yields. */
|
|
73
|
+
declare const Token: ZodType;
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/types/type-defs.d.ts
|
|
76
|
+
/**
|
|
77
|
+
* The named types `@venn-lang/auth` publishes to scripts, keyed by their bare name.
|
|
78
|
+
*
|
|
79
|
+
* `Token` restates `OAuthToken` in `port/auth-client.types.ts` and, field for
|
|
80
|
+
* field, the Zod `Token` next door. Each serves a different reader: Zod checks a
|
|
81
|
+
* value that arrived, this types a call being written. Change all three together.
|
|
82
|
+
*/
|
|
83
|
+
declare const authTypeDefs: Readonly<Record<string, TypeSpec>>;
|
|
84
|
+
//#endregion
|
|
85
|
+
export { type AuthClient, AuthClientPort, type OAuthToken, type OAuthTokenRequest, Token, authActions, authPlugin, authPlugin as default, authTypeDefs, createFakeAuthClient, createRealAuthClient };
|
|
86
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/actions/index.ts","../src/port/auth-client.types.ts","../src/port/auth-client.port.ts","../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/plugin.ts","../src/types/token.ts","../src/types/type-defs.ts"],"mappings":";;;;;cAUa,aAAa;;;;UCTT;EACf;EACA;EACA;EACA;EACA;;;UAIe;EACf;EACA;EACA;;;;;;;UAQe;EACf,MAAM,SAAS,oBAAoB,QAAQ;;;;;;;;;;;cCZhC,gBAAgB,KAAK;;;;;;;;iBCHlB,qBAAqB;EAAQ,QAAQ,QAAQ;IAAqB;;;;;;;;;iBCElE,wBAAwB;;;;;;;;;;cCE3B,YAAY;;;;cCRZ,OAAO;;;;;;;;;;cCMP,cAAc,SAAS,eAAe"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { arg, defineAction, definePlugin, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { VennError } from "@venn-lang/contracts";
|
|
4
|
+
/**
|
|
5
|
+
* `auth.apikey(key, { header })`: a one-entry header object carrying an API key.
|
|
6
|
+
*
|
|
7
|
+
* The header name defaults to `X-API-Key`. Pure, no network.
|
|
8
|
+
*/
|
|
9
|
+
const apikey = defineAction({
|
|
10
|
+
name: "apikey",
|
|
11
|
+
doc: "Build a header object carrying an API key.",
|
|
12
|
+
params: z.object({ header: z.string().optional() }).optional(),
|
|
13
|
+
args: [arg("key", t.string, "The API key itself.")],
|
|
14
|
+
result: t.ref("auth.Headers"),
|
|
15
|
+
run: (_ctx, input) => {
|
|
16
|
+
return { [input.params?.header ?? "X-API-Key"]: String(input.args[0] ?? "") };
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/actions/basic.ts
|
|
21
|
+
/**
|
|
22
|
+
* `auth.basic(username, password)`: an `Authorization: Basic …` header.
|
|
23
|
+
*
|
|
24
|
+
* The credentials are base64 of `user:pass`, which is encoding and not secrecy.
|
|
25
|
+
* A password read from `secrets.*` stays redacted in every diagnostic.
|
|
26
|
+
*/
|
|
27
|
+
const basic = defineAction({
|
|
28
|
+
name: "basic",
|
|
29
|
+
doc: "Build a Basic Authorization header from a username and password.",
|
|
30
|
+
args: [arg("username", t.string, "Who is signing in."), arg("password", t.string, "Their password. A secret stays redacted.")],
|
|
31
|
+
result: t.ref("auth.Headers"),
|
|
32
|
+
run: (_ctx, input) => basicHeader(String(input.args[0] ?? ""), String(input.args[1] ?? ""))
|
|
33
|
+
});
|
|
34
|
+
function basicHeader(user, pass) {
|
|
35
|
+
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/actions/bearer.ts
|
|
39
|
+
/** `auth.bearer(token)`: an `Authorization: Bearer …` header. Pure, no network. */
|
|
40
|
+
const bearer = defineAction({
|
|
41
|
+
name: "bearer",
|
|
42
|
+
doc: "Build an Authorization header for a bearer token.",
|
|
43
|
+
args: [arg("token", t.string, "The token to carry.")],
|
|
44
|
+
result: t.ref("auth.Headers"),
|
|
45
|
+
run: (_ctx, input) => ({ Authorization: `Bearer ${String(input.args[0] ?? "")}` })
|
|
46
|
+
});
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/crypto/bytes.ts
|
|
49
|
+
/** UTF-8 encode a string. The copy is ArrayBuffer-backed, the only shape WebCrypto accepts. */
|
|
50
|
+
function encodeUtf8(text) {
|
|
51
|
+
return Uint8Array.from(new TextEncoder().encode(text));
|
|
52
|
+
}
|
|
53
|
+
/** Lowercase hex of a byte array. */
|
|
54
|
+
function toHex(bytes) {
|
|
55
|
+
let out = "";
|
|
56
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** URL-safe, unpadded base64 (base64url) of a byte array. */
|
|
60
|
+
function toBase64Url(bytes) {
|
|
61
|
+
let binary = "";
|
|
62
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
63
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/crypto/hmac.ts
|
|
67
|
+
const ALGOS = {
|
|
68
|
+
sha1: "SHA-1",
|
|
69
|
+
sha256: "SHA-256",
|
|
70
|
+
sha384: "SHA-384",
|
|
71
|
+
sha512: "SHA-512"
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Map whatever the script wrote (`sha256`, `SHA-256`) to a WebCrypto hash name.
|
|
75
|
+
*
|
|
76
|
+
* An unknown label falls back to SHA-256 rather than throwing.
|
|
77
|
+
*/
|
|
78
|
+
function normalizeHash(algo) {
|
|
79
|
+
const key = (algo ?? "sha256").toLowerCase().replace(/-/g, "");
|
|
80
|
+
return ALGOS[key] ?? "SHA-256";
|
|
81
|
+
}
|
|
82
|
+
/** Raw HMAC bytes over `message`, keyed by `secret`, using the global Web Crypto. */
|
|
83
|
+
async function hmacRaw(args) {
|
|
84
|
+
const key = await crypto.subtle.importKey("raw", encodeUtf8(args.secret), {
|
|
85
|
+
name: "HMAC",
|
|
86
|
+
hash: normalizeHash(args.algo)
|
|
87
|
+
}, false, ["sign"]);
|
|
88
|
+
const signature = await crypto.subtle.sign("HMAC", key, args.message);
|
|
89
|
+
return new Uint8Array(signature);
|
|
90
|
+
}
|
|
91
|
+
/** Lowercase hex HMAC over a string `payload`, keyed by `secret` (default SHA-256). */
|
|
92
|
+
async function hmacHex(args) {
|
|
93
|
+
return toHex(await hmacRaw({
|
|
94
|
+
secret: args.secret,
|
|
95
|
+
message: encodeUtf8(args.payload),
|
|
96
|
+
algo: args.algo
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/crypto/jwt.ts
|
|
101
|
+
/**
|
|
102
|
+
* Sign a compact HS256 JWT.
|
|
103
|
+
*
|
|
104
|
+
* @param args.header Merged over `{ alg: "HS256", typ: "JWT" }`, so a caller can
|
|
105
|
+
* add claims like `kid` but overriding `alg` does not change how it is signed.
|
|
106
|
+
*/
|
|
107
|
+
async function signJwt(args) {
|
|
108
|
+
const signingInput = `${encodeSegment({
|
|
109
|
+
alg: "HS256",
|
|
110
|
+
typ: "JWT",
|
|
111
|
+
...args.header ?? {}
|
|
112
|
+
})}.${encodeSegment(args.payload)}`;
|
|
113
|
+
return `${signingInput}.${toBase64Url(await hmacRaw({
|
|
114
|
+
secret: args.secret,
|
|
115
|
+
message: encodeUtf8(signingInput),
|
|
116
|
+
algo: "sha256"
|
|
117
|
+
}))}`;
|
|
118
|
+
}
|
|
119
|
+
function encodeSegment(value) {
|
|
120
|
+
return toBase64Url(encodeUtf8(JSON.stringify(value)));
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/crypto/totp.ts
|
|
124
|
+
/**
|
|
125
|
+
* An RFC 6238 TOTP code: SHA-1, a 30 second period and 6 digits by default.
|
|
126
|
+
*
|
|
127
|
+
* @param args.at The instant to compute for, in seconds. Fixing it fixes the code.
|
|
128
|
+
*/
|
|
129
|
+
async function totpCode(args) {
|
|
130
|
+
const period = args.period ?? 30;
|
|
131
|
+
const digits = args.digits ?? 6;
|
|
132
|
+
const counter = Math.floor((args.at ?? 0) / period);
|
|
133
|
+
return truncate({
|
|
134
|
+
mac: await hmacRaw({
|
|
135
|
+
secret: args.seed,
|
|
136
|
+
message: counterBytes(counter),
|
|
137
|
+
algo: "sha1"
|
|
138
|
+
}),
|
|
139
|
+
digits
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** 8-byte big-endian counter, per HOTP (RFC 4226). */
|
|
143
|
+
function counterBytes(counter) {
|
|
144
|
+
const buffer = /* @__PURE__ */ new Uint8Array(8);
|
|
145
|
+
let remaining = counter;
|
|
146
|
+
for (let i = 7; i >= 0; i -= 1) {
|
|
147
|
+
buffer[i] = remaining & 255;
|
|
148
|
+
remaining = Math.floor(remaining / 256);
|
|
149
|
+
}
|
|
150
|
+
return buffer;
|
|
151
|
+
}
|
|
152
|
+
/** Dynamic truncation of an HMAC into a zero-padded decimal code (RFC 4226 §5.3). */
|
|
153
|
+
function truncate(args) {
|
|
154
|
+
const mac = args.mac;
|
|
155
|
+
const offset = byteAt(mac, mac.length - 1) & 15;
|
|
156
|
+
const binary = (byteAt(mac, offset) & 127) << 24 | byteAt(mac, offset + 1) << 16 | byteAt(mac, offset + 2) << 8 | byteAt(mac, offset + 3);
|
|
157
|
+
return String(binary % 10 ** args.digits).padStart(args.digits, "0");
|
|
158
|
+
}
|
|
159
|
+
function byteAt(bytes, index) {
|
|
160
|
+
return bytes[index] ?? 0;
|
|
161
|
+
}
|
|
162
|
+
/** `auth.hmac(secret, payload, { algo })`: a lowercase hex HMAC. Defaults to SHA-256. */
|
|
163
|
+
const hmac = defineAction({
|
|
164
|
+
name: "hmac",
|
|
165
|
+
doc: "Compute a hex HMAC over a payload using a secret.",
|
|
166
|
+
params: z.object({ algo: z.string().optional() }).optional(),
|
|
167
|
+
args: [arg("payload", t.string, "What to sign."), arg("secret", t.string, "The shared secret.")],
|
|
168
|
+
result: t.string,
|
|
169
|
+
run: (_ctx, input) => hmacHex({
|
|
170
|
+
secret: String(input.args[0] ?? ""),
|
|
171
|
+
payload: String(input.args[1] ?? ""),
|
|
172
|
+
algo: input.params?.algo
|
|
173
|
+
})
|
|
174
|
+
});
|
|
175
|
+
/** `auth.jwt({ header, payload, secret })`: a compact HS256-signed JWT. */
|
|
176
|
+
const jwt = defineAction({
|
|
177
|
+
name: "jwt",
|
|
178
|
+
doc: "Sign an HS256 JWT.",
|
|
179
|
+
params: z.object({
|
|
180
|
+
header: z.record(z.string(), z.unknown()).optional(),
|
|
181
|
+
payload: z.unknown(),
|
|
182
|
+
secret: z.string()
|
|
183
|
+
}),
|
|
184
|
+
result: t.string,
|
|
185
|
+
run: (_ctx, input) => signJwt({
|
|
186
|
+
header: input.params.header,
|
|
187
|
+
payload: input.params.payload,
|
|
188
|
+
secret: input.params.secret
|
|
189
|
+
})
|
|
190
|
+
});
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/port/auth-client.port.ts
|
|
193
|
+
/**
|
|
194
|
+
* The port `auth.oauth2` obtains its tokens through.
|
|
195
|
+
*
|
|
196
|
+
* Bound by the host to `createFakeAuthClient` or `createRealAuthClient`.
|
|
197
|
+
* Requires the `net` capability, so a host without it is refused at load time
|
|
198
|
+
* rather than failing mid-run.
|
|
199
|
+
*/
|
|
200
|
+
const AuthClientPort = {
|
|
201
|
+
id: "venn.port.auth-client",
|
|
202
|
+
version: 1,
|
|
203
|
+
requires: ["net"],
|
|
204
|
+
methods: ["token"]
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* `auth.oauth2(principal, { grant, tokenUrl, scope, refresh })`: an `auth.Token`.
|
|
208
|
+
*
|
|
209
|
+
* The only verb here that leaves the process, and it does so through
|
|
210
|
+
* `AuthClientPort` rather than calling the endpoint itself.
|
|
211
|
+
*/
|
|
212
|
+
const oauth2 = defineAction({
|
|
213
|
+
name: "oauth2",
|
|
214
|
+
doc: "Obtain an OAuth2 token for a principal via the AuthClient port.",
|
|
215
|
+
params: z.object({
|
|
216
|
+
grant: z.string().optional(),
|
|
217
|
+
tokenUrl: z.string().optional(),
|
|
218
|
+
scope: z.string().optional(),
|
|
219
|
+
refresh: z.string().optional()
|
|
220
|
+
}).optional(),
|
|
221
|
+
args: [arg("principal", t.string, "Who the token is for.")],
|
|
222
|
+
result: t.ref("auth.Token"),
|
|
223
|
+
run: (ctx, input) => ctx.port(AuthClientPort).token({
|
|
224
|
+
principal: String(input.args[0] ?? ""),
|
|
225
|
+
grant: input.params?.grant,
|
|
226
|
+
tokenUrl: input.params?.tokenUrl,
|
|
227
|
+
scope: input.params?.scope,
|
|
228
|
+
refresh: input.params?.refresh
|
|
229
|
+
})
|
|
230
|
+
});
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/actions/index.ts
|
|
233
|
+
/** The auth namespace's verbs. Adding one is a single line here. */
|
|
234
|
+
const authActions = [
|
|
235
|
+
bearer,
|
|
236
|
+
basic,
|
|
237
|
+
apikey,
|
|
238
|
+
hmac,
|
|
239
|
+
defineAction({
|
|
240
|
+
name: "totp",
|
|
241
|
+
doc: "Compute a TOTP code from a shared seed.",
|
|
242
|
+
params: z.object({
|
|
243
|
+
at: z.number().optional(),
|
|
244
|
+
period: z.number().optional(),
|
|
245
|
+
digits: z.number().optional()
|
|
246
|
+
}).optional(),
|
|
247
|
+
args: [arg("seed", t.string, "The shared seed, base32.")],
|
|
248
|
+
result: t.string,
|
|
249
|
+
run: (_ctx, input) => totpCode({
|
|
250
|
+
seed: String(input.args[0] ?? ""),
|
|
251
|
+
at: input.params?.at,
|
|
252
|
+
period: input.params?.period,
|
|
253
|
+
digits: input.params?.digits
|
|
254
|
+
})
|
|
255
|
+
}),
|
|
256
|
+
jwt,
|
|
257
|
+
oauth2
|
|
258
|
+
];
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/clients/fake-client.ts
|
|
261
|
+
/**
|
|
262
|
+
* The in-process `AuthClient`: a canned token derived from the principal, no network.
|
|
263
|
+
*
|
|
264
|
+
* @param args.token Fields to override on every token it hands back.
|
|
265
|
+
*/
|
|
266
|
+
function createFakeAuthClient(args = {}) {
|
|
267
|
+
return { token: async (request) => ({
|
|
268
|
+
access_token: `fake-access-token:${request.principal}`,
|
|
269
|
+
token_type: "Bearer",
|
|
270
|
+
expires_in: 3600,
|
|
271
|
+
...args.token
|
|
272
|
+
}) };
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/clients/real-client.ts
|
|
276
|
+
/**
|
|
277
|
+
* The endpoint-backed `AuthClient`. Not implemented in this build.
|
|
278
|
+
*
|
|
279
|
+
* @throws VennError `VN8090` on every call, so a host that binds it fails loudly
|
|
280
|
+
* instead of quietly handing a script a token that was never obtained.
|
|
281
|
+
*/
|
|
282
|
+
function createRealAuthClient() {
|
|
283
|
+
return { token: async () => {
|
|
284
|
+
throw new VennError({
|
|
285
|
+
code: "VN8090",
|
|
286
|
+
message: "OAuth2 real client not implemented in this build"
|
|
287
|
+
});
|
|
288
|
+
} };
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/types/token.ts
|
|
292
|
+
/** Runtime schema for the nominal `Token` type, the value `auth.oauth2` yields. */
|
|
293
|
+
const Token = z.object({
|
|
294
|
+
access_token: z.string(),
|
|
295
|
+
token_type: z.string(),
|
|
296
|
+
expires_in: z.number()
|
|
297
|
+
});
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/types/type-defs.ts
|
|
300
|
+
/**
|
|
301
|
+
* The named types `@venn-lang/auth` publishes to scripts, keyed by their bare name.
|
|
302
|
+
*
|
|
303
|
+
* `Token` restates `OAuthToken` in `port/auth-client.types.ts` and, field for
|
|
304
|
+
* field, the Zod `Token` next door. Each serves a different reader: Zod checks a
|
|
305
|
+
* value that arrived, this types a call being written. Change all three together.
|
|
306
|
+
*/
|
|
307
|
+
const authTypeDefs = {
|
|
308
|
+
/**
|
|
309
|
+
* What the header builders hand back, ready to go into a request's `headers`.
|
|
310
|
+
*
|
|
311
|
+
* A map rather than a record: `auth.apikey` names its own header, so the key
|
|
312
|
+
* is only known at the call. The values are always strings.
|
|
313
|
+
*/
|
|
314
|
+
Headers: t.map(t.string),
|
|
315
|
+
/** An OAuth2 token, as the token endpoint answered with it. */
|
|
316
|
+
Token: t.record({
|
|
317
|
+
access_token: t.string,
|
|
318
|
+
token_type: t.string,
|
|
319
|
+
expires_in: t.number
|
|
320
|
+
})
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/plugin.ts
|
|
324
|
+
/**
|
|
325
|
+
* The `auth` plugin: header builders, signing helpers and an OAuth2 exchange.
|
|
326
|
+
*
|
|
327
|
+
* Every verb but `auth.oauth2` is pure. That one reaches out through
|
|
328
|
+
* `AuthClientPort`, which is why the whole plugin declares the `net` capability
|
|
329
|
+
* and is refused at load time on a host that cannot offer it.
|
|
330
|
+
*/
|
|
331
|
+
const authPlugin = definePlugin({
|
|
332
|
+
name: "venn/auth",
|
|
333
|
+
version: "0.0.0",
|
|
334
|
+
namespace: "auth",
|
|
335
|
+
requires: ["net"],
|
|
336
|
+
actions: authActions,
|
|
337
|
+
types: { Token },
|
|
338
|
+
typeDefs: authTypeDefs
|
|
339
|
+
});
|
|
340
|
+
//#endregion
|
|
341
|
+
export { AuthClientPort, Token, authActions, authPlugin, authPlugin as default, authTypeDefs, createFakeAuthClient, createRealAuthClient };
|
|
342
|
+
|
|
343
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["params","params","params","params"],"sources":["../src/actions/apikey.ts","../src/actions/basic.ts","../src/actions/bearer.ts","../src/crypto/bytes.ts","../src/crypto/hmac.ts","../src/crypto/jwt.ts","../src/crypto/totp.ts","../src/actions/hmac.ts","../src/actions/jwt.ts","../src/port/auth-client.port.ts","../src/actions/oauth2.ts","../src/actions/totp.ts","../src/actions/index.ts","../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/types/token.ts","../src/types/type-defs.ts","../src/plugin.ts"],"sourcesContent":["import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\n\nconst params = z.object({ header: z.string().optional() }).optional();\n\n/**\n * `auth.apikey(key, { header })`: a one-entry header object carrying an API key.\n *\n * The header name defaults to `X-API-Key`. Pure, no network.\n */\nexport const apikey: ActionDefinition = defineAction({\n name: \"apikey\",\n doc: \"Build a header object carrying an API key.\",\n params,\n args: [arg(\"key\", t.string, \"The API key itself.\")],\n result: t.ref(\"auth.Headers\"),\n run: (_ctx, input) => {\n const header = input.params?.header ?? \"X-API-Key\";\n return { [header]: String(input.args[0] ?? \"\") };\n },\n});\n","import { type ActionDefinition, arg, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\n\n/**\n * `auth.basic(username, password)`: an `Authorization: Basic …` header.\n *\n * The credentials are base64 of `user:pass`, which is encoding and not secrecy.\n * A password read from `secrets.*` stays redacted in every diagnostic.\n */\nexport const basic: ActionDefinition = defineAction({\n name: \"basic\",\n doc: \"Build a Basic Authorization header from a username and password.\",\n args: [\n arg(\"username\", t.string, \"Who is signing in.\"),\n arg(\"password\", t.string, \"Their password. A secret stays redacted.\"),\n ],\n result: t.ref(\"auth.Headers\"),\n run: (_ctx, input) => basicHeader(String(input.args[0] ?? \"\"), String(input.args[1] ?? \"\")),\n});\n\nfunction basicHeader(user: string, pass: string): { Authorization: string } {\n return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };\n}\n","import { type ActionDefinition, arg, defineAction } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\n\n/** `auth.bearer(token)`: an `Authorization: Bearer …` header. Pure, no network. */\nexport const bearer: ActionDefinition = defineAction({\n name: \"bearer\",\n doc: \"Build an Authorization header for a bearer token.\",\n args: [arg(\"token\", t.string, \"The token to carry.\")],\n result: t.ref(\"auth.Headers\"),\n run: (_ctx, input) => ({ Authorization: `Bearer ${String(input.args[0] ?? \"\")}` }),\n});\n","// Pure byte encoders shared by the HMAC, TOTP and JWT builders. No I/O and no\n// ports, only deterministic transforms over Web-standard primitives.\n\n/** UTF-8 encode a string. The copy is ArrayBuffer-backed, the only shape WebCrypto accepts. */\nexport function encodeUtf8(text: string): Uint8Array<ArrayBuffer> {\n return Uint8Array.from(new TextEncoder().encode(text));\n}\n\n/** Lowercase hex of a byte array. */\nexport function toHex(bytes: Uint8Array): string {\n let out = \"\";\n for (const byte of bytes) out += byte.toString(16).padStart(2, \"0\");\n return out;\n}\n\n/** URL-safe, unpadded base64 (base64url) of a byte array. */\nexport function toBase64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n","import { encodeUtf8, toHex } from \"./bytes.js\";\n\nconst ALGOS: Record<string, string> = {\n sha1: \"SHA-1\",\n sha256: \"SHA-256\",\n sha384: \"SHA-384\",\n sha512: \"SHA-512\",\n};\n\n/**\n * Map whatever the script wrote (`sha256`, `SHA-256`) to a WebCrypto hash name.\n *\n * An unknown label falls back to SHA-256 rather than throwing.\n */\nexport function normalizeHash(algo?: string): string {\n const key = (algo ?? \"sha256\").toLowerCase().replace(/-/g, \"\");\n return ALGOS[key] ?? \"SHA-256\";\n}\n\n/** Raw HMAC bytes over `message`, keyed by `secret`, using the global Web Crypto. */\nexport async function hmacRaw(args: {\n secret: string;\n message: Uint8Array<ArrayBuffer>;\n algo?: string;\n}): Promise<Uint8Array> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encodeUtf8(args.secret),\n { name: \"HMAC\", hash: normalizeHash(args.algo) },\n false,\n [\"sign\"],\n );\n const signature = await crypto.subtle.sign(\"HMAC\", key, args.message);\n return new Uint8Array(signature);\n}\n\n/** Lowercase hex HMAC over a string `payload`, keyed by `secret` (default SHA-256). */\nexport async function hmacHex(args: {\n secret: string;\n payload: string;\n algo?: string;\n}): Promise<string> {\n return toHex(\n await hmacRaw({ secret: args.secret, message: encodeUtf8(args.payload), algo: args.algo }),\n );\n}\n","import { encodeUtf8, toBase64Url } from \"./bytes.js\";\nimport { hmacRaw } from \"./hmac.js\";\n\n/**\n * Sign a compact HS256 JWT.\n *\n * @param args.header Merged over `{ alg: \"HS256\", typ: \"JWT\" }`, so a caller can\n * add claims like `kid` but overriding `alg` does not change how it is signed.\n */\nexport async function signJwt(args: {\n header?: Record<string, unknown>;\n payload: unknown;\n secret: string;\n}): Promise<string> {\n const header = { alg: \"HS256\", typ: \"JWT\", ...(args.header ?? {}) };\n const signingInput = `${encodeSegment(header)}.${encodeSegment(args.payload)}`;\n const signature = await hmacRaw({\n secret: args.secret,\n message: encodeUtf8(signingInput),\n algo: \"sha256\",\n });\n return `${signingInput}.${toBase64Url(signature)}`;\n}\n\nfunction encodeSegment(value: unknown): string {\n return toBase64Url(encodeUtf8(JSON.stringify(value)));\n}\n","import { hmacRaw } from \"./hmac.js\";\n\n/**\n * An RFC 6238 TOTP code: SHA-1, a 30 second period and 6 digits by default.\n *\n * @param args.at The instant to compute for, in seconds. Fixing it fixes the code.\n */\nexport async function totpCode(args: {\n seed: string;\n at?: number;\n period?: number;\n digits?: number;\n}): Promise<string> {\n const period = args.period ?? 30;\n const digits = args.digits ?? 6;\n const counter = Math.floor((args.at ?? 0) / period);\n const mac = await hmacRaw({ secret: args.seed, message: counterBytes(counter), algo: \"sha1\" });\n return truncate({ mac, digits });\n}\n\n/** 8-byte big-endian counter, per HOTP (RFC 4226). */\nfunction counterBytes(counter: number): Uint8Array<ArrayBuffer> {\n const buffer = new Uint8Array(8);\n let remaining = counter;\n for (let i = 7; i >= 0; i -= 1) {\n buffer[i] = remaining & 0xff;\n remaining = Math.floor(remaining / 256);\n }\n return buffer;\n}\n\n/** Dynamic truncation of an HMAC into a zero-padded decimal code (RFC 4226 §5.3). */\nfunction truncate(args: { mac: Uint8Array; digits: number }): string {\n const mac = args.mac;\n const offset = byteAt(mac, mac.length - 1) & 0x0f;\n const binary =\n ((byteAt(mac, offset) & 0x7f) << 24) |\n (byteAt(mac, offset + 1) << 16) |\n (byteAt(mac, offset + 2) << 8) |\n byteAt(mac, offset + 3);\n return String(binary % 10 ** args.digits).padStart(args.digits, \"0\");\n}\n\nfunction byteAt(bytes: Uint8Array, index: number): number {\n return bytes[index] ?? 0;\n}\n","import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { hmacHex } from \"../crypto/index.js\";\n\nconst params = z.object({ algo: z.string().optional() }).optional();\n\n/** `auth.hmac(secret, payload, { algo })`: a lowercase hex HMAC. Defaults to SHA-256. */\nexport const hmac: ActionDefinition = defineAction({\n name: \"hmac\",\n doc: \"Compute a hex HMAC over a payload using a secret.\",\n params,\n args: [arg(\"payload\", t.string, \"What to sign.\"), arg(\"secret\", t.string, \"The shared secret.\")],\n result: t.string,\n run: (_ctx, input) =>\n hmacHex({\n secret: String(input.args[0] ?? \"\"),\n payload: String(input.args[1] ?? \"\"),\n algo: input.params?.algo,\n }),\n});\n","import { type ActionDefinition, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { signJwt } from \"../crypto/index.js\";\n\nconst params = z.object({\n header: z.record(z.string(), z.unknown()).optional(),\n payload: z.unknown(),\n secret: z.string(),\n});\n\n/** `auth.jwt({ header, payload, secret })`: a compact HS256-signed JWT. */\nexport const jwt: ActionDefinition = defineAction({\n name: \"jwt\",\n doc: \"Sign an HS256 JWT.\",\n params,\n // Header, payload and secret are all options, so nothing goes positionally.\n result: t.string,\n run: (_ctx, input) =>\n signJwt({\n header: input.params.header,\n payload: input.params.payload,\n secret: input.params.secret,\n }),\n});\n","import type { Port } from \"@venn-lang/contracts\";\nimport type { AuthClient } from \"./auth-client.types.js\";\n\n/**\n * The port `auth.oauth2` obtains its tokens through.\n *\n * Bound by the host to `createFakeAuthClient` or `createRealAuthClient`.\n * Requires the `net` capability, so a host without it is refused at load time\n * rather than failing mid-run.\n */\nexport const AuthClientPort: Port<AuthClient> = {\n id: \"venn.port.auth-client\",\n version: 1,\n requires: [\"net\"],\n methods: [\"token\"],\n};\n","import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { AuthClientPort } from \"../port/index.js\";\n\nconst params = z\n .object({\n grant: z.string().optional(),\n tokenUrl: z.string().optional(),\n scope: z.string().optional(),\n refresh: z.string().optional(),\n })\n .optional();\n\n/**\n * `auth.oauth2(principal, { grant, tokenUrl, scope, refresh })`: an `auth.Token`.\n *\n * The only verb here that leaves the process, and it does so through\n * `AuthClientPort` rather than calling the endpoint itself.\n */\nexport const oauth2: ActionDefinition = defineAction({\n name: \"oauth2\",\n doc: \"Obtain an OAuth2 token for a principal via the AuthClient port.\",\n params,\n args: [arg(\"principal\", t.string, \"Who the token is for.\")],\n result: t.ref(\"auth.Token\"),\n run: (ctx, input) =>\n ctx.port(AuthClientPort).token({\n principal: String(input.args[0] ?? \"\"),\n grant: input.params?.grant,\n tokenUrl: input.params?.tokenUrl,\n scope: input.params?.scope,\n refresh: input.params?.refresh,\n }),\n});\n","import { type ActionDefinition, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { totpCode } from \"../crypto/index.js\";\n\nconst params = z\n .object({\n at: z.number().optional(),\n period: z.number().optional(),\n digits: z.number().optional(),\n })\n .optional();\n\n/**\n * `auth.totp(seed, { at, period, digits })`: an RFC 6238 one-time code.\n *\n * Pin `at` to a fixed instant to get the same code on every run.\n */\nexport const totp: ActionDefinition = defineAction({\n name: \"totp\",\n doc: \"Compute a TOTP code from a shared seed.\",\n params,\n // A code is digits, but it is written and compared as text: leading zeros are\n // part of it, so the result is a string and not a number.\n args: [arg(\"seed\", t.string, \"The shared seed, base32.\")],\n result: t.string,\n run: (_ctx, input) =>\n totpCode({\n seed: String(input.args[0] ?? \"\"),\n at: input.params?.at,\n period: input.params?.period,\n digits: input.params?.digits,\n }),\n});\n","import type { ActionDefinition } from \"@venn-lang/sdk\";\nimport { apikey } from \"./apikey.js\";\nimport { basic } from \"./basic.js\";\nimport { bearer } from \"./bearer.js\";\nimport { hmac } from \"./hmac.js\";\nimport { jwt } from \"./jwt.js\";\nimport { oauth2 } from \"./oauth2.js\";\nimport { totp } from \"./totp.js\";\n\n/** The auth namespace's verbs. Adding one is a single line here. */\nexport const authActions: ActionDefinition[] = [bearer, basic, apikey, hmac, totp, jwt, oauth2];\n","import type { AuthClient, OAuthToken } from \"../port/index.js\";\n\n/**\n * The in-process `AuthClient`: a canned token derived from the principal, no network.\n *\n * @param args.token Fields to override on every token it hands back.\n */\nexport function createFakeAuthClient(args: { token?: Partial<OAuthToken> } = {}): AuthClient {\n return {\n token: async (request) => ({\n access_token: `fake-access-token:${request.principal}`,\n token_type: \"Bearer\",\n expires_in: 3600,\n ...args.token,\n }),\n };\n}\n","import { VennError } from \"@venn-lang/contracts\";\nimport type { AuthClient } from \"../port/index.js\";\n\n/**\n * The endpoint-backed `AuthClient`. Not implemented in this build.\n *\n * @throws VennError `VN8090` on every call, so a host that binds it fails loudly\n * instead of quietly handing a script a token that was never obtained.\n */\nexport function createRealAuthClient(): AuthClient {\n return {\n token: async () => {\n throw new VennError({\n code: \"VN8090\",\n message: \"OAuth2 real client not implemented in this build\",\n });\n },\n };\n}\n","import { type ZodType, z } from \"@venn-lang/sdk\";\n\n/** Runtime schema for the nominal `Token` type, the value `auth.oauth2` yields. */\nexport const Token: ZodType = z.object({\n access_token: z.string(),\n token_type: z.string(),\n expires_in: z.number(),\n});\n","import { type TypeSpec, t } from \"@venn-lang/types\";\n\n/**\n * The named types `@venn-lang/auth` publishes to scripts, keyed by their bare name.\n *\n * `Token` restates `OAuthToken` in `port/auth-client.types.ts` and, field for\n * field, the Zod `Token` next door. Each serves a different reader: Zod checks a\n * value that arrived, this types a call being written. Change all three together.\n */\nexport const authTypeDefs: Readonly<Record<string, TypeSpec>> = {\n /**\n * What the header builders hand back, ready to go into a request's `headers`.\n *\n * A map rather than a record: `auth.apikey` names its own header, so the key\n * is only known at the call. The values are always strings.\n */\n Headers: t.map(t.string),\n /** An OAuth2 token, as the token endpoint answered with it. */\n Token: t.record({\n access_token: t.string,\n token_type: t.string,\n expires_in: t.number,\n }),\n};\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { authActions } from \"./actions/index.js\";\nimport { authTypeDefs, Token } from \"./types/index.js\";\n\n/**\n * The `auth` plugin: header builders, signing helpers and an OAuth2 exchange.\n *\n * Every verb but `auth.oauth2` is pure. That one reaches out through\n * `AuthClientPort`, which is why the whole plugin declares the `net` capability\n * and is refused at load time on a host that cannot offer it.\n */\nexport const authPlugin: PluginDefinition = definePlugin({\n name: \"venn/auth\",\n version: \"0.0.0\",\n namespace: \"auth\",\n requires: [\"net\"],\n actions: authActions,\n types: { Token },\n typeDefs: authTypeDefs,\n});\n\nexport { authPlugin as default };\n"],"mappings":";;;;;;;;AAUA,MAAa,SAA2B,aAAa;CACnD,MAAM;CACN,KAAK;CACL,QAVa,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,SAUzD;CACA,MAAM,CAAC,IAAI,OAAO,EAAE,QAAQ,qBAAqB,CAAC;CAClD,QAAQ,EAAE,IAAI,cAAc;CAC5B,MAAM,MAAM,UAAU;EAEpB,OAAO,GADQ,MAAM,QAAQ,UAAU,cACpB,OAAO,MAAM,KAAK,MAAM,EAAE,EAAE;CACjD;AACF,CAAC;;;;;;;;;ACXD,MAAa,QAA0B,aAAa;CAClD,MAAM;CACN,KAAK;CACL,MAAM,CACJ,IAAI,YAAY,EAAE,QAAQ,oBAAoB,GAC9C,IAAI,YAAY,EAAE,QAAQ,0CAA0C,CACtE;CACA,QAAQ,EAAE,IAAI,cAAc;CAC5B,MAAM,MAAM,UAAU,YAAY,OAAO,MAAM,KAAK,MAAM,EAAE,GAAG,OAAO,MAAM,KAAK,MAAM,EAAE,CAAC;AAC5F,CAAC;AAED,SAAS,YAAY,MAAc,MAAyC;CAC1E,OAAO,EAAE,eAAe,SAAS,KAAK,GAAG,KAAK,GAAG,MAAM,IAAI;AAC7D;;;;AClBA,MAAa,SAA2B,aAAa;CACnD,MAAM;CACN,KAAK;CACL,MAAM,CAAC,IAAI,SAAS,EAAE,QAAQ,qBAAqB,CAAC;CACpD,QAAQ,EAAE,IAAI,cAAc;CAC5B,MAAM,MAAM,WAAW,EAAE,eAAe,UAAU,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI;AAClF,CAAC;;;;ACND,SAAgB,WAAW,MAAuC;CAChE,OAAO,WAAW,KAAK,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI,CAAC;AACvD;;AAGA,SAAgB,MAAM,OAA2B;CAC/C,IAAI,MAAM;CACV,KAAK,MAAM,QAAQ,OAAO,OAAO,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CAClE,OAAO;AACT;;AAGA,SAAgB,YAAY,OAA2B;CACrD,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;AAC/E;;;AClBA,MAAM,QAAgC;CACpC,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;;;;;;AAOA,SAAgB,cAAc,MAAuB;CACnD,MAAM,OAAO,QAAQ,SAAA,CAAU,YAAY,CAAC,CAAC,QAAQ,MAAM,EAAE;CAC7D,OAAO,MAAM,QAAQ;AACvB;;AAGA,eAAsB,QAAQ,MAIN;CACtB,MAAM,MAAM,MAAM,OAAO,OAAO,UAC9B,OACA,WAAW,KAAK,MAAM,GACtB;EAAE,MAAM;EAAQ,MAAM,cAAc,KAAK,IAAI;CAAE,GAC/C,OACA,CAAC,MAAM,CACT;CACA,MAAM,YAAY,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,KAAK,OAAO;CACpE,OAAO,IAAI,WAAW,SAAS;AACjC;;AAGA,eAAsB,QAAQ,MAIV;CAClB,OAAO,MACL,MAAM,QAAQ;EAAE,QAAQ,KAAK;EAAQ,SAAS,WAAW,KAAK,OAAO;EAAG,MAAM,KAAK;CAAK,CAAC,CAC3F;AACF;;;;;;;;;ACpCA,eAAsB,QAAQ,MAIV;CAElB,MAAM,eAAe,GAAG,cAAc;EADrB,KAAK;EAAS,KAAK;EAAO,GAAI,KAAK,UAAU,CAAC;CACpB,CAAC,EAAE,GAAG,cAAc,KAAK,OAAO;CAM3E,OAAO,GAAG,aAAa,GAAG,YAAY,MALd,QAAQ;EAC9B,QAAQ,KAAK;EACb,SAAS,WAAW,YAAY;EAChC,MAAM;CACR,CAAC,CAC8C;AACjD;AAEA,SAAS,cAAc,OAAwB;CAC7C,OAAO,YAAY,WAAW,KAAK,UAAU,KAAK,CAAC,CAAC;AACtD;;;;;;;;ACnBA,eAAsB,SAAS,MAKX;CAClB,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,UAAU,KAAK,OAAO,KAAK,MAAM,KAAK,MAAM;CAElD,OAAO,SAAS;EAAE,KAAA,MADA,QAAQ;GAAE,QAAQ,KAAK;GAAM,SAAS,aAAa,OAAO;GAAG,MAAM;EAAO,CAAC;EACtE;CAAO,CAAC;AACjC;;AAGA,SAAS,aAAa,SAA0C;CAC9D,MAAM,yBAAS,IAAI,WAAW,CAAC;CAC/B,IAAI,YAAY;CAChB,KAAK,IAAI,IAAI,GAAG,KAAK,GAAG,KAAK,GAAG;EAC9B,OAAO,KAAK,YAAY;EACxB,YAAY,KAAK,MAAM,YAAY,GAAG;CACxC;CACA,OAAO;AACT;;AAGA,SAAS,SAAS,MAAmD;CACnE,MAAM,MAAM,KAAK;CACjB,MAAM,SAAS,OAAO,KAAK,IAAI,SAAS,CAAC,IAAI;CAC7C,MAAM,UACF,OAAO,KAAK,MAAM,IAAI,QAAS,KAChC,OAAO,KAAK,SAAS,CAAC,KAAK,KAC3B,OAAO,KAAK,SAAS,CAAC,KAAK,IAC5B,OAAO,KAAK,SAAS,CAAC;CACxB,OAAO,OAAO,SAAS,MAAM,KAAK,MAAM,CAAC,CAAC,SAAS,KAAK,QAAQ,GAAG;AACrE;AAEA,SAAS,OAAO,OAAmB,OAAuB;CACxD,OAAO,MAAM,UAAU;AACzB;;ACtCA,MAAa,OAAyB,aAAa;CACjD,MAAM;CACN,KAAK;CACL,QANa,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,SAMvD;CACA,MAAM,CAAC,IAAI,WAAW,EAAE,QAAQ,eAAe,GAAG,IAAI,UAAU,EAAE,QAAQ,oBAAoB,CAAC;CAC/F,QAAQ,EAAE;CACV,MAAM,MAAM,UACV,QAAQ;EACN,QAAQ,OAAO,MAAM,KAAK,MAAM,EAAE;EAClC,SAAS,OAAO,MAAM,KAAK,MAAM,EAAE;EACnC,MAAM,MAAM,QAAQ;CACtB,CAAC;AACL,CAAC;;ACRD,MAAa,MAAwB,aAAa;CAChD,MAAM;CACN,KAAK;CACL,QAVa,EAAE,OAAO;EACtB,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS;EACnD,SAAS,EAAE,QAAQ;EACnB,QAAQ,EAAE,OAAO;CACnB,CAME;CAEA,QAAQ,EAAE;CACV,MAAM,MAAM,UACV,QAAQ;EACN,QAAQ,MAAM,OAAO;EACrB,SAAS,MAAM,OAAO;EACtB,QAAQ,MAAM,OAAO;CACvB,CAAC;AACL,CAAC;;;;;;;;;;ACbD,MAAa,iBAAmC;CAC9C,IAAI;CACJ,SAAS;CACT,UAAU,CAAC,KAAK;CAChB,SAAS,CAAC,OAAO;AACnB;;;;;;;ACIA,MAAa,SAA2B,aAAa;CACnD,MAAM;CACN,KAAK;CACL,QAlBa,EACZ,OAAO;EACN,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;EAC3B,UAAU,EAAE,OAAO,CAAC,CAAC,SAAS;EAC9B,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;EAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,CAAC,CAAC,CACD,SAWD;CACA,MAAM,CAAC,IAAI,aAAa,EAAE,QAAQ,uBAAuB,CAAC;CAC1D,QAAQ,EAAE,IAAI,YAAY;CAC1B,MAAM,KAAK,UACT,IAAI,KAAK,cAAc,CAAC,CAAC,MAAM;EAC7B,WAAW,OAAO,MAAM,KAAK,MAAM,EAAE;EACrC,OAAO,MAAM,QAAQ;EACrB,UAAU,MAAM,QAAQ;EACxB,OAAO,MAAM,QAAQ;EACrB,SAAS,MAAM,QAAQ;CACzB,CAAC;AACL,CAAC;;;;AEvBD,MAAa,cAAkC;CAAC;CAAQ;CAAO;CAAQ;CDOjC,aAAa;EACjD,MAAM;EACN,KAAK;EACL,QAhBa,EACZ,OAAO;GACN,IAAI,EAAE,OAAO,CAAC,CAAC,SAAS;GACxB,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;GAC5B,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;EAC9B,CAAC,CAAC,CACD,SAUD;EAGA,MAAM,CAAC,IAAI,QAAQ,EAAE,QAAQ,0BAA0B,CAAC;EACxD,QAAQ,EAAE;EACV,MAAM,MAAM,UACV,SAAS;GACP,MAAM,OAAO,MAAM,KAAK,MAAM,EAAE;GAChC,IAAI,MAAM,QAAQ;GAClB,QAAQ,MAAM,QAAQ;GACtB,QAAQ,MAAM,QAAQ;EACxB,CAAC;CACL,CCtB6E;CAAM;CAAK;AAAM;;;;;;;;ACH9F,SAAgB,qBAAqB,OAAwC,CAAC,GAAe;CAC3F,OAAO,EACL,OAAO,OAAO,aAAa;EACzB,cAAc,qBAAqB,QAAQ;EAC3C,YAAY;EACZ,YAAY;EACZ,GAAG,KAAK;CACV,GACF;AACF;;;;;;;;;ACPA,SAAgB,uBAAmC;CACjD,OAAO,EACL,OAAO,YAAY;EACjB,MAAM,IAAI,UAAU;GAClB,MAAM;GACN,SAAS;EACX,CAAC;CACH,EACF;AACF;;;;ACfA,MAAa,QAAiB,EAAE,OAAO;CACrC,cAAc,EAAE,OAAO;CACvB,YAAY,EAAE,OAAO;CACrB,YAAY,EAAE,OAAO;AACvB,CAAC;;;;;;;;;;ACED,MAAa,eAAmD;;;;;;;CAO9D,SAAS,EAAE,IAAI,EAAE,MAAM;;CAEvB,OAAO,EAAE,OAAO;EACd,cAAc,EAAE;EAChB,YAAY,EAAE;EACd,YAAY,EAAE;CAChB,CAAC;AACH;;;;;;;;;;ACZA,MAAa,aAA+B,aAAa;CACvD,MAAM;CACN,SAAS;CACT,WAAW;CACX,UAAU,CAAC,KAAK;CAChB,SAAS;CACT,OAAO,EAAE,MAAM;CACf,UAAU;AACZ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@venn-lang/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The auth namespace: build the header a request needs, or fetch a token for it.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"venn",
|
|
7
|
+
"testing",
|
|
8
|
+
"e2e",
|
|
9
|
+
"auth"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-auth#readme",
|
|
12
|
+
"bugs": "https://github.com/venn-lang/venn/issues",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/venn-lang/venn.git",
|
|
16
|
+
"directory": "packages/std-auth"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Vinicius Borges",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/**/*.test.ts",
|
|
34
|
+
"!src/**/*.suite.ts"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@venn-lang/contracts": "0.1.0",
|
|
41
|
+
"@venn-lang/sdk": "0.1.0",
|
|
42
|
+
"@venn-lang/types": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"tsdown": "^0.22.14",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vitest": "^4.1.10"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
|
|
4
|
+
const params = z.object({ header: z.string().optional() }).optional();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `auth.apikey(key, { header })`: a one-entry header object carrying an API key.
|
|
8
|
+
*
|
|
9
|
+
* The header name defaults to `X-API-Key`. Pure, no network.
|
|
10
|
+
*/
|
|
11
|
+
export const apikey: ActionDefinition = defineAction({
|
|
12
|
+
name: "apikey",
|
|
13
|
+
doc: "Build a header object carrying an API key.",
|
|
14
|
+
params,
|
|
15
|
+
args: [arg("key", t.string, "The API key itself.")],
|
|
16
|
+
result: t.ref("auth.Headers"),
|
|
17
|
+
run: (_ctx, input) => {
|
|
18
|
+
const header = input.params?.header ?? "X-API-Key";
|
|
19
|
+
return { [header]: String(input.args[0] ?? "") };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `auth.basic(username, password)`: an `Authorization: Basic …` header.
|
|
6
|
+
*
|
|
7
|
+
* The credentials are base64 of `user:pass`, which is encoding and not secrecy.
|
|
8
|
+
* A password read from `secrets.*` stays redacted in every diagnostic.
|
|
9
|
+
*/
|
|
10
|
+
export const basic: ActionDefinition = defineAction({
|
|
11
|
+
name: "basic",
|
|
12
|
+
doc: "Build a Basic Authorization header from a username and password.",
|
|
13
|
+
args: [
|
|
14
|
+
arg("username", t.string, "Who is signing in."),
|
|
15
|
+
arg("password", t.string, "Their password. A secret stays redacted."),
|
|
16
|
+
],
|
|
17
|
+
result: t.ref("auth.Headers"),
|
|
18
|
+
run: (_ctx, input) => basicHeader(String(input.args[0] ?? ""), String(input.args[1] ?? "")),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function basicHeader(user: string, pass: string): { Authorization: string } {
|
|
22
|
+
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
|
|
4
|
+
/** `auth.bearer(token)`: an `Authorization: Bearer …` header. Pure, no network. */
|
|
5
|
+
export const bearer: ActionDefinition = defineAction({
|
|
6
|
+
name: "bearer",
|
|
7
|
+
doc: "Build an Authorization header for a bearer token.",
|
|
8
|
+
args: [arg("token", t.string, "The token to carry.")],
|
|
9
|
+
result: t.ref("auth.Headers"),
|
|
10
|
+
run: (_ctx, input) => ({ Authorization: `Bearer ${String(input.args[0] ?? "")}` }),
|
|
11
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { hmacHex } from "../crypto/index.js";
|
|
4
|
+
|
|
5
|
+
const params = z.object({ algo: z.string().optional() }).optional();
|
|
6
|
+
|
|
7
|
+
/** `auth.hmac(secret, payload, { algo })`: a lowercase hex HMAC. Defaults to SHA-256. */
|
|
8
|
+
export const hmac: ActionDefinition = defineAction({
|
|
9
|
+
name: "hmac",
|
|
10
|
+
doc: "Compute a hex HMAC over a payload using a secret.",
|
|
11
|
+
params,
|
|
12
|
+
args: [arg("payload", t.string, "What to sign."), arg("secret", t.string, "The shared secret.")],
|
|
13
|
+
result: t.string,
|
|
14
|
+
run: (_ctx, input) =>
|
|
15
|
+
hmacHex({
|
|
16
|
+
secret: String(input.args[0] ?? ""),
|
|
17
|
+
payload: String(input.args[1] ?? ""),
|
|
18
|
+
algo: input.params?.algo,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ActionDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { apikey } from "./apikey.js";
|
|
3
|
+
import { basic } from "./basic.js";
|
|
4
|
+
import { bearer } from "./bearer.js";
|
|
5
|
+
import { hmac } from "./hmac.js";
|
|
6
|
+
import { jwt } from "./jwt.js";
|
|
7
|
+
import { oauth2 } from "./oauth2.js";
|
|
8
|
+
import { totp } from "./totp.js";
|
|
9
|
+
|
|
10
|
+
/** The auth namespace's verbs. Adding one is a single line here. */
|
|
11
|
+
export const authActions: ActionDefinition[] = [bearer, basic, apikey, hmac, totp, jwt, oauth2];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ActionDefinition, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { signJwt } from "../crypto/index.js";
|
|
4
|
+
|
|
5
|
+
const params = z.object({
|
|
6
|
+
header: z.record(z.string(), z.unknown()).optional(),
|
|
7
|
+
payload: z.unknown(),
|
|
8
|
+
secret: z.string(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** `auth.jwt({ header, payload, secret })`: a compact HS256-signed JWT. */
|
|
12
|
+
export const jwt: ActionDefinition = defineAction({
|
|
13
|
+
name: "jwt",
|
|
14
|
+
doc: "Sign an HS256 JWT.",
|
|
15
|
+
params,
|
|
16
|
+
// Header, payload and secret are all options, so nothing goes positionally.
|
|
17
|
+
result: t.string,
|
|
18
|
+
run: (_ctx, input) =>
|
|
19
|
+
signJwt({
|
|
20
|
+
header: input.params.header,
|
|
21
|
+
payload: input.params.payload,
|
|
22
|
+
secret: input.params.secret,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { AuthClientPort } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
const params = z
|
|
6
|
+
.object({
|
|
7
|
+
grant: z.string().optional(),
|
|
8
|
+
tokenUrl: z.string().optional(),
|
|
9
|
+
scope: z.string().optional(),
|
|
10
|
+
refresh: z.string().optional(),
|
|
11
|
+
})
|
|
12
|
+
.optional();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `auth.oauth2(principal, { grant, tokenUrl, scope, refresh })`: an `auth.Token`.
|
|
16
|
+
*
|
|
17
|
+
* The only verb here that leaves the process, and it does so through
|
|
18
|
+
* `AuthClientPort` rather than calling the endpoint itself.
|
|
19
|
+
*/
|
|
20
|
+
export const oauth2: ActionDefinition = defineAction({
|
|
21
|
+
name: "oauth2",
|
|
22
|
+
doc: "Obtain an OAuth2 token for a principal via the AuthClient port.",
|
|
23
|
+
params,
|
|
24
|
+
args: [arg("principal", t.string, "Who the token is for.")],
|
|
25
|
+
result: t.ref("auth.Token"),
|
|
26
|
+
run: (ctx, input) =>
|
|
27
|
+
ctx.port(AuthClientPort).token({
|
|
28
|
+
principal: String(input.args[0] ?? ""),
|
|
29
|
+
grant: input.params?.grant,
|
|
30
|
+
tokenUrl: input.params?.tokenUrl,
|
|
31
|
+
scope: input.params?.scope,
|
|
32
|
+
refresh: input.params?.refresh,
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { totpCode } from "../crypto/index.js";
|
|
4
|
+
|
|
5
|
+
const params = z
|
|
6
|
+
.object({
|
|
7
|
+
at: z.number().optional(),
|
|
8
|
+
period: z.number().optional(),
|
|
9
|
+
digits: z.number().optional(),
|
|
10
|
+
})
|
|
11
|
+
.optional();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `auth.totp(seed, { at, period, digits })`: an RFC 6238 one-time code.
|
|
15
|
+
*
|
|
16
|
+
* Pin `at` to a fixed instant to get the same code on every run.
|
|
17
|
+
*/
|
|
18
|
+
export const totp: ActionDefinition = defineAction({
|
|
19
|
+
name: "totp",
|
|
20
|
+
doc: "Compute a TOTP code from a shared seed.",
|
|
21
|
+
params,
|
|
22
|
+
// A code is digits, but it is written and compared as text: leading zeros are
|
|
23
|
+
// part of it, so the result is a string and not a number.
|
|
24
|
+
args: [arg("seed", t.string, "The shared seed, base32.")],
|
|
25
|
+
result: t.string,
|
|
26
|
+
run: (_ctx, input) =>
|
|
27
|
+
totpCode({
|
|
28
|
+
seed: String(input.args[0] ?? ""),
|
|
29
|
+
at: input.params?.at,
|
|
30
|
+
period: input.params?.period,
|
|
31
|
+
digits: input.params?.digits,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AuthClient, OAuthToken } from "../port/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The in-process `AuthClient`: a canned token derived from the principal, no network.
|
|
5
|
+
*
|
|
6
|
+
* @param args.token Fields to override on every token it hands back.
|
|
7
|
+
*/
|
|
8
|
+
export function createFakeAuthClient(args: { token?: Partial<OAuthToken> } = {}): AuthClient {
|
|
9
|
+
return {
|
|
10
|
+
token: async (request) => ({
|
|
11
|
+
access_token: `fake-access-token:${request.principal}`,
|
|
12
|
+
token_type: "Bearer",
|
|
13
|
+
expires_in: 3600,
|
|
14
|
+
...args.token,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import type { AuthClient } from "../port/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The endpoint-backed `AuthClient`. Not implemented in this build.
|
|
6
|
+
*
|
|
7
|
+
* @throws VennError `VN8090` on every call, so a host that binds it fails loudly
|
|
8
|
+
* instead of quietly handing a script a token that was never obtained.
|
|
9
|
+
*/
|
|
10
|
+
export function createRealAuthClient(): AuthClient {
|
|
11
|
+
return {
|
|
12
|
+
token: async () => {
|
|
13
|
+
throw new VennError({
|
|
14
|
+
code: "VN8090",
|
|
15
|
+
message: "OAuth2 real client not implemented in this build",
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure byte encoders shared by the HMAC, TOTP and JWT builders. No I/O and no
|
|
2
|
+
// ports, only deterministic transforms over Web-standard primitives.
|
|
3
|
+
|
|
4
|
+
/** UTF-8 encode a string. The copy is ArrayBuffer-backed, the only shape WebCrypto accepts. */
|
|
5
|
+
export function encodeUtf8(text: string): Uint8Array<ArrayBuffer> {
|
|
6
|
+
return Uint8Array.from(new TextEncoder().encode(text));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Lowercase hex of a byte array. */
|
|
10
|
+
export function toHex(bytes: Uint8Array): string {
|
|
11
|
+
let out = "";
|
|
12
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** URL-safe, unpadded base64 (base64url) of a byte array. */
|
|
17
|
+
export function toBase64Url(bytes: Uint8Array): string {
|
|
18
|
+
let binary = "";
|
|
19
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
20
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { encodeUtf8, toHex } from "./bytes.js";
|
|
2
|
+
|
|
3
|
+
const ALGOS: Record<string, string> = {
|
|
4
|
+
sha1: "SHA-1",
|
|
5
|
+
sha256: "SHA-256",
|
|
6
|
+
sha384: "SHA-384",
|
|
7
|
+
sha512: "SHA-512",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Map whatever the script wrote (`sha256`, `SHA-256`) to a WebCrypto hash name.
|
|
12
|
+
*
|
|
13
|
+
* An unknown label falls back to SHA-256 rather than throwing.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeHash(algo?: string): string {
|
|
16
|
+
const key = (algo ?? "sha256").toLowerCase().replace(/-/g, "");
|
|
17
|
+
return ALGOS[key] ?? "SHA-256";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Raw HMAC bytes over `message`, keyed by `secret`, using the global Web Crypto. */
|
|
21
|
+
export async function hmacRaw(args: {
|
|
22
|
+
secret: string;
|
|
23
|
+
message: Uint8Array<ArrayBuffer>;
|
|
24
|
+
algo?: string;
|
|
25
|
+
}): Promise<Uint8Array> {
|
|
26
|
+
const key = await crypto.subtle.importKey(
|
|
27
|
+
"raw",
|
|
28
|
+
encodeUtf8(args.secret),
|
|
29
|
+
{ name: "HMAC", hash: normalizeHash(args.algo) },
|
|
30
|
+
false,
|
|
31
|
+
["sign"],
|
|
32
|
+
);
|
|
33
|
+
const signature = await crypto.subtle.sign("HMAC", key, args.message);
|
|
34
|
+
return new Uint8Array(signature);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lowercase hex HMAC over a string `payload`, keyed by `secret` (default SHA-256). */
|
|
38
|
+
export async function hmacHex(args: {
|
|
39
|
+
secret: string;
|
|
40
|
+
payload: string;
|
|
41
|
+
algo?: string;
|
|
42
|
+
}): Promise<string> {
|
|
43
|
+
return toHex(
|
|
44
|
+
await hmacRaw({ secret: args.secret, message: encodeUtf8(args.payload), algo: args.algo }),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { encodeUtf8, toBase64Url } from "./bytes.js";
|
|
2
|
+
import { hmacRaw } from "./hmac.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sign a compact HS256 JWT.
|
|
6
|
+
*
|
|
7
|
+
* @param args.header Merged over `{ alg: "HS256", typ: "JWT" }`, so a caller can
|
|
8
|
+
* add claims like `kid` but overriding `alg` does not change how it is signed.
|
|
9
|
+
*/
|
|
10
|
+
export async function signJwt(args: {
|
|
11
|
+
header?: Record<string, unknown>;
|
|
12
|
+
payload: unknown;
|
|
13
|
+
secret: string;
|
|
14
|
+
}): Promise<string> {
|
|
15
|
+
const header = { alg: "HS256", typ: "JWT", ...(args.header ?? {}) };
|
|
16
|
+
const signingInput = `${encodeSegment(header)}.${encodeSegment(args.payload)}`;
|
|
17
|
+
const signature = await hmacRaw({
|
|
18
|
+
secret: args.secret,
|
|
19
|
+
message: encodeUtf8(signingInput),
|
|
20
|
+
algo: "sha256",
|
|
21
|
+
});
|
|
22
|
+
return `${signingInput}.${toBase64Url(signature)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeSegment(value: unknown): string {
|
|
26
|
+
return toBase64Url(encodeUtf8(JSON.stringify(value)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { hmacRaw } from "./hmac.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An RFC 6238 TOTP code: SHA-1, a 30 second period and 6 digits by default.
|
|
5
|
+
*
|
|
6
|
+
* @param args.at The instant to compute for, in seconds. Fixing it fixes the code.
|
|
7
|
+
*/
|
|
8
|
+
export async function totpCode(args: {
|
|
9
|
+
seed: string;
|
|
10
|
+
at?: number;
|
|
11
|
+
period?: number;
|
|
12
|
+
digits?: number;
|
|
13
|
+
}): Promise<string> {
|
|
14
|
+
const period = args.period ?? 30;
|
|
15
|
+
const digits = args.digits ?? 6;
|
|
16
|
+
const counter = Math.floor((args.at ?? 0) / period);
|
|
17
|
+
const mac = await hmacRaw({ secret: args.seed, message: counterBytes(counter), algo: "sha1" });
|
|
18
|
+
return truncate({ mac, digits });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 8-byte big-endian counter, per HOTP (RFC 4226). */
|
|
22
|
+
function counterBytes(counter: number): Uint8Array<ArrayBuffer> {
|
|
23
|
+
const buffer = new Uint8Array(8);
|
|
24
|
+
let remaining = counter;
|
|
25
|
+
for (let i = 7; i >= 0; i -= 1) {
|
|
26
|
+
buffer[i] = remaining & 0xff;
|
|
27
|
+
remaining = Math.floor(remaining / 256);
|
|
28
|
+
}
|
|
29
|
+
return buffer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Dynamic truncation of an HMAC into a zero-padded decimal code (RFC 4226 §5.3). */
|
|
33
|
+
function truncate(args: { mac: Uint8Array; digits: number }): string {
|
|
34
|
+
const mac = args.mac;
|
|
35
|
+
const offset = byteAt(mac, mac.length - 1) & 0x0f;
|
|
36
|
+
const binary =
|
|
37
|
+
((byteAt(mac, offset) & 0x7f) << 24) |
|
|
38
|
+
(byteAt(mac, offset + 1) << 16) |
|
|
39
|
+
(byteAt(mac, offset + 2) << 8) |
|
|
40
|
+
byteAt(mac, offset + 3);
|
|
41
|
+
return String(binary % 10 ** args.digits).padStart(args.digits, "0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byteAt(bytes: Uint8Array, index: number): number {
|
|
45
|
+
return bytes[index] ?? 0;
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @venn-lang/auth: pure token and header builders (bearer, basic, apikey, hmac, totp,
|
|
2
|
+
// jwt), plus `oauth2`, which rides the AuthClient port so a live token exchange is
|
|
3
|
+
// injected rather than baked in. Signing uses the global Web Crypto.
|
|
4
|
+
|
|
5
|
+
export * from "./actions/index.js";
|
|
6
|
+
export * from "./clients/index.js";
|
|
7
|
+
export { authPlugin, authPlugin as default } from "./plugin.js";
|
|
8
|
+
export * from "./port/index.js";
|
|
9
|
+
export * from "./types/index.js";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { authActions } from "./actions/index.js";
|
|
3
|
+
import { authTypeDefs, Token } from "./types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `auth` plugin: header builders, signing helpers and an OAuth2 exchange.
|
|
7
|
+
*
|
|
8
|
+
* Every verb but `auth.oauth2` is pure. That one reaches out through
|
|
9
|
+
* `AuthClientPort`, which is why the whole plugin declares the `net` capability
|
|
10
|
+
* and is refused at load time on a host that cannot offer it.
|
|
11
|
+
*/
|
|
12
|
+
export const authPlugin: PluginDefinition = definePlugin({
|
|
13
|
+
name: "venn/auth",
|
|
14
|
+
version: "0.0.0",
|
|
15
|
+
namespace: "auth",
|
|
16
|
+
requires: ["net"],
|
|
17
|
+
actions: authActions,
|
|
18
|
+
types: { Token },
|
|
19
|
+
typeDefs: authTypeDefs,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { authPlugin as default };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Port } from "@venn-lang/contracts";
|
|
2
|
+
import type { AuthClient } from "./auth-client.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The port `auth.oauth2` obtains its tokens through.
|
|
6
|
+
*
|
|
7
|
+
* Bound by the host to `createFakeAuthClient` or `createRealAuthClient`.
|
|
8
|
+
* Requires the `net` capability, so a host without it is refused at load time
|
|
9
|
+
* rather than failing mid-run.
|
|
10
|
+
*/
|
|
11
|
+
export const AuthClientPort: Port<AuthClient> = {
|
|
12
|
+
id: "venn.port.auth-client",
|
|
13
|
+
version: 1,
|
|
14
|
+
requires: ["net"],
|
|
15
|
+
methods: ["token"],
|
|
16
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Arguments for {@link AuthClient.token}: which principal, and how to obtain or refresh it. */
|
|
2
|
+
export interface OAuthTokenRequest {
|
|
3
|
+
principal: string;
|
|
4
|
+
grant?: string;
|
|
5
|
+
tokenUrl?: string;
|
|
6
|
+
scope?: string;
|
|
7
|
+
refresh?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A token as the endpoint answered with it. Snake case, because that is the wire shape. */
|
|
11
|
+
export interface OAuthToken {
|
|
12
|
+
access_token: string;
|
|
13
|
+
token_type: string;
|
|
14
|
+
expires_in: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The contract `auth.oauth2` acquires tokens through.
|
|
19
|
+
*
|
|
20
|
+
* Reached via `ctx.port(AuthClientPort)`, never by calling an endpoint directly.
|
|
21
|
+
*/
|
|
22
|
+
export interface AuthClient {
|
|
23
|
+
token(request: OAuthTokenRequest): Promise<OAuthToken>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ZodType, z } from "@venn-lang/sdk";
|
|
2
|
+
|
|
3
|
+
/** Runtime schema for the nominal `Token` type, the value `auth.oauth2` yields. */
|
|
4
|
+
export const Token: ZodType = z.object({
|
|
5
|
+
access_token: z.string(),
|
|
6
|
+
token_type: z.string(),
|
|
7
|
+
expires_in: z.number(),
|
|
8
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type TypeSpec, t } from "@venn-lang/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The named types `@venn-lang/auth` publishes to scripts, keyed by their bare name.
|
|
5
|
+
*
|
|
6
|
+
* `Token` restates `OAuthToken` in `port/auth-client.types.ts` and, field for
|
|
7
|
+
* field, the Zod `Token` next door. Each serves a different reader: Zod checks a
|
|
8
|
+
* value that arrived, this types a call being written. Change all three together.
|
|
9
|
+
*/
|
|
10
|
+
export const authTypeDefs: Readonly<Record<string, TypeSpec>> = {
|
|
11
|
+
/**
|
|
12
|
+
* What the header builders hand back, ready to go into a request's `headers`.
|
|
13
|
+
*
|
|
14
|
+
* A map rather than a record: `auth.apikey` names its own header, so the key
|
|
15
|
+
* is only known at the call. The values are always strings.
|
|
16
|
+
*/
|
|
17
|
+
Headers: t.map(t.string),
|
|
18
|
+
/** An OAuth2 token, as the token endpoint answered with it. */
|
|
19
|
+
Token: t.record({
|
|
20
|
+
access_token: t.string,
|
|
21
|
+
token_type: t.string,
|
|
22
|
+
expires_in: t.number,
|
|
23
|
+
}),
|
|
24
|
+
};
|