@grantforge/client 0.0.0-stage → 2026.0.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/dist/auth.d.ts +67 -0
- package/dist/auth.js +168 -0
- package/dist/client.d.ts +47 -0
- package/dist/client.js +72 -0
- package/dist/discovery.d.ts +11 -0
- package/dist/discovery.js +25 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/pkce.d.ts +8 -0
- package/dist/pkce.js +33 -0
- package/dist/vue.d.ts +27 -0
- package/dist/vue.js +83 -0
- package/package.json +48 -4
- package/README.md +0 -3
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** How a browser application signs its users in through GrantForge. */
|
|
2
|
+
export interface AuthOptions {
|
|
3
|
+
/** GrantForge's address, the issuer of its tokens, such as https://grantforge.example.com. */
|
|
4
|
+
issuer: string;
|
|
5
|
+
/** The application's public client ID, registered in GrantForge's resource catalog. */
|
|
6
|
+
clientId: string;
|
|
7
|
+
/** Where GrantForge sends users back to; one of the client's redirect URIs. */
|
|
8
|
+
redirectUri: string;
|
|
9
|
+
/** The scopes to ask for; openid and permissions by default. */
|
|
10
|
+
scope?: string;
|
|
11
|
+
/** Where tokens and pending sign-ins are kept; the session storage by default, so they end with the tab. */
|
|
12
|
+
storage?: Storage;
|
|
13
|
+
/** The fetch to call GrantForge with. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
/** Navigates the browser; location.assign by default. */
|
|
16
|
+
navigate?: (url: string) => void;
|
|
17
|
+
/** The current time in milliseconds. */
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
/** The tokens of the signed-in user. */
|
|
21
|
+
export interface Tokens {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
refreshToken: string | null;
|
|
24
|
+
idToken: string | null;
|
|
25
|
+
/** When the access token expires, in milliseconds. */
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Signs users in through GrantForge with the authorization code flow and PKCE, as a public client, and keeps their tokens
|
|
30
|
+
* fresh. Refresh tokens rotate on every use and GrantForge revokes everything when a replaced one comes back, so one
|
|
31
|
+
* refresh runs at a time however many calls need a token.
|
|
32
|
+
*/
|
|
33
|
+
export declare class GrantForgeAuth {
|
|
34
|
+
private readonly options;
|
|
35
|
+
private readonly scope;
|
|
36
|
+
private readonly storage;
|
|
37
|
+
private readonly fetcher;
|
|
38
|
+
private readonly navigate;
|
|
39
|
+
private readonly now;
|
|
40
|
+
private endpoints;
|
|
41
|
+
private refreshing;
|
|
42
|
+
constructor(options: AuthOptions);
|
|
43
|
+
/** Sends the browser to GrantForge to sign in; it comes back to the redirect URI, where handleRedirect takes over. */
|
|
44
|
+
login(returnTo?: string | null): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Finishes a sign-in at the redirect URI: exchanges the code for tokens and checks the ID token's nonce.
|
|
47
|
+
*
|
|
48
|
+
* @returns where the user was when login was called, if given
|
|
49
|
+
*/
|
|
50
|
+
handleRedirect(url?: string): Promise<{
|
|
51
|
+
returnTo: string | null;
|
|
52
|
+
}>;
|
|
53
|
+
/** Returns a valid access token, refreshing it when it is about to expire; null if the user must sign in again. */
|
|
54
|
+
accessToken(): Promise<string | null>;
|
|
55
|
+
/** Returns whether a user is signed in in this browser. */
|
|
56
|
+
get signedIn(): boolean;
|
|
57
|
+
/** Returns the claims of the ID token: sub, preferred_username, name, email, tid. */
|
|
58
|
+
user(): Record<string, unknown> | null;
|
|
59
|
+
/** Forgets the user's tokens in this browser and asks GrantForge to revoke the refresh token. */
|
|
60
|
+
logout(): Promise<void>;
|
|
61
|
+
/** Forgets the tokens without telling GrantForge, as when it refused them. */
|
|
62
|
+
forget(): void;
|
|
63
|
+
private refresh;
|
|
64
|
+
private tokenRequest;
|
|
65
|
+
private stored;
|
|
66
|
+
private discovered;
|
|
67
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
import { discover } from './discovery.js';
|
|
6
|
+
import { errorOf, GrantForgeError } from './errors.js';
|
|
7
|
+
import { challengeOf, claimsOf, randomToken } from './pkce.js';
|
|
8
|
+
const TOKENS = 'grantforge.tokens';
|
|
9
|
+
const PENDING = 'grantforge.pending.';
|
|
10
|
+
/** Access tokens are renewed this long before they expire, so calls do not race their expiry. */
|
|
11
|
+
const EARLY = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* Signs users in through GrantForge with the authorization code flow and PKCE, as a public client, and keeps their tokens
|
|
14
|
+
* fresh. Refresh tokens rotate on every use and GrantForge revokes everything when a replaced one comes back, so one
|
|
15
|
+
* refresh runs at a time however many calls need a token.
|
|
16
|
+
*/
|
|
17
|
+
export class GrantForgeAuth {
|
|
18
|
+
options;
|
|
19
|
+
scope;
|
|
20
|
+
storage;
|
|
21
|
+
fetcher;
|
|
22
|
+
navigate;
|
|
23
|
+
now;
|
|
24
|
+
endpoints = null;
|
|
25
|
+
refreshing = null;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.scope = options.scope ?? 'openid permissions';
|
|
29
|
+
this.storage = options.storage ?? sessionStorage;
|
|
30
|
+
this.fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
31
|
+
this.navigate = options.navigate ?? (url => window.location.assign(url));
|
|
32
|
+
this.now = options.now ?? (() => Date.now());
|
|
33
|
+
}
|
|
34
|
+
/** Sends the browser to GrantForge to sign in; it comes back to the redirect URI, where handleRedirect takes over. */
|
|
35
|
+
async login(returnTo = null) {
|
|
36
|
+
const endpoints = await this.discovered();
|
|
37
|
+
const state = randomToken(), verifier = randomToken(), nonce = randomToken();
|
|
38
|
+
this.storage.setItem(PENDING + state, JSON.stringify({ verifier, nonce, returnTo }));
|
|
39
|
+
const query = new URLSearchParams({ response_type: 'code', client_id: this.options.clientId, redirect_uri: this.options.redirectUri,
|
|
40
|
+
scope: this.scope, state, nonce, code_challenge: await challengeOf(verifier), code_challenge_method: 'S256' });
|
|
41
|
+
this.navigate(`${endpoints.authorization}?${query}`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Finishes a sign-in at the redirect URI: exchanges the code for tokens and checks the ID token's nonce.
|
|
45
|
+
*
|
|
46
|
+
* @returns where the user was when login was called, if given
|
|
47
|
+
*/
|
|
48
|
+
async handleRedirect(url = window.location.href) {
|
|
49
|
+
const params = new URL(url).searchParams;
|
|
50
|
+
const error = params.get('error');
|
|
51
|
+
if (error)
|
|
52
|
+
throw new GrantForgeError(error === 'access_denied' ? 'forbidden' : 'invalid', `GrantForge refused the sign-in: ${error}`);
|
|
53
|
+
const code = params.get('code'), state = params.get('state');
|
|
54
|
+
const stored = state ? this.storage.getItem(PENDING + state) : null;
|
|
55
|
+
if (!code || !state || !stored)
|
|
56
|
+
throw new GrantForgeError('invalid', 'No sign-in of this browser waits for this answer');
|
|
57
|
+
this.storage.removeItem(PENDING + state);
|
|
58
|
+
const pending = JSON.parse(stored);
|
|
59
|
+
const tokens = await this.tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri,
|
|
60
|
+
code_verifier: pending.verifier });
|
|
61
|
+
if (tokens.idToken && claimsOf(tokens.idToken).nonce !== pending.nonce) {
|
|
62
|
+
this.storage.removeItem(TOKENS);
|
|
63
|
+
throw new GrantForgeError('invalid', 'The ID token does not answer this sign-in');
|
|
64
|
+
}
|
|
65
|
+
return { returnTo: pending.returnTo };
|
|
66
|
+
}
|
|
67
|
+
/** Returns a valid access token, refreshing it when it is about to expire; null if the user must sign in again. */
|
|
68
|
+
async accessToken() {
|
|
69
|
+
const tokens = this.stored();
|
|
70
|
+
if (!tokens)
|
|
71
|
+
return null;
|
|
72
|
+
if (tokens.expiresAt - EARLY > this.now())
|
|
73
|
+
return tokens.accessToken;
|
|
74
|
+
if (!tokens.refreshToken)
|
|
75
|
+
return null;
|
|
76
|
+
const refreshed = await this.refresh(tokens.refreshToken);
|
|
77
|
+
return refreshed?.accessToken ?? null;
|
|
78
|
+
}
|
|
79
|
+
/** Returns whether a user is signed in in this browser. */
|
|
80
|
+
get signedIn() {
|
|
81
|
+
return this.stored() !== null;
|
|
82
|
+
}
|
|
83
|
+
/** Returns the claims of the ID token: sub, preferred_username, name, email, tid. */
|
|
84
|
+
user() {
|
|
85
|
+
const idToken = this.stored()?.idToken;
|
|
86
|
+
return idToken ? claimsOf(idToken) : null;
|
|
87
|
+
}
|
|
88
|
+
/** Forgets the user's tokens in this browser and asks GrantForge to revoke the refresh token. */
|
|
89
|
+
async logout() {
|
|
90
|
+
const tokens = this.stored();
|
|
91
|
+
this.storage.removeItem(TOKENS);
|
|
92
|
+
if (!tokens?.refreshToken)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
const endpoints = await this.discovered();
|
|
96
|
+
if (endpoints.revocation) {
|
|
97
|
+
await this.fetcher(endpoints.revocation, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: 'refresh_token', client_id: this.options.clientId }) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Signing out locally does not wait on GrantForge; the token expires anyway.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Forgets the tokens without telling GrantForge, as when it refused them. */
|
|
106
|
+
forget() {
|
|
107
|
+
this.storage.removeItem(TOKENS);
|
|
108
|
+
}
|
|
109
|
+
refresh(refreshToken) {
|
|
110
|
+
this.refreshing ??= this.tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken })
|
|
111
|
+
.then(tokens => tokens)
|
|
112
|
+
.catch(failure => {
|
|
113
|
+
// A refused refresh token will not work again: the user signs in anew.
|
|
114
|
+
if (failure instanceof GrantForgeError && failure.reason !== 'unavailable') {
|
|
115
|
+
this.storage.removeItem(TOKENS);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
throw failure;
|
|
119
|
+
})
|
|
120
|
+
.finally(() => { this.refreshing = null; });
|
|
121
|
+
return this.refreshing;
|
|
122
|
+
}
|
|
123
|
+
async tokenRequest(form) {
|
|
124
|
+
const endpoints = await this.discovered();
|
|
125
|
+
let response;
|
|
126
|
+
try {
|
|
127
|
+
response = await this.fetcher(endpoints.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
128
|
+
body: new URLSearchParams({ ...form, client_id: this.options.clientId }) });
|
|
129
|
+
}
|
|
130
|
+
catch (failure) {
|
|
131
|
+
throw new GrantForgeError('unavailable', `GrantForge did not answer: ${String(failure)}`);
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
// OAuth answers refused grants with 400 invalid_grant: the user must sign in again.
|
|
135
|
+
throw errorOf(response.status === 400 ? new Response(null, { status: 401 }) : response, 'The token endpoint');
|
|
136
|
+
}
|
|
137
|
+
const answer = await response.json();
|
|
138
|
+
if (typeof answer.access_token !== 'string')
|
|
139
|
+
throw new GrantForgeError('unavailable', 'The token endpoint answered without a token');
|
|
140
|
+
const previous = this.stored();
|
|
141
|
+
const tokens = {
|
|
142
|
+
accessToken: answer.access_token,
|
|
143
|
+
refreshToken: typeof answer.refresh_token === 'string' ? answer.refresh_token : previous?.refreshToken ?? null,
|
|
144
|
+
idToken: typeof answer.id_token === 'string' ? answer.id_token : previous?.idToken ?? null,
|
|
145
|
+
expiresAt: this.now() + (typeof answer.expires_in === 'number' ? answer.expires_in : 300) * 1000,
|
|
146
|
+
};
|
|
147
|
+
this.storage.setItem(TOKENS, JSON.stringify(tokens));
|
|
148
|
+
return tokens;
|
|
149
|
+
}
|
|
150
|
+
stored() {
|
|
151
|
+
const stored = this.storage.getItem(TOKENS);
|
|
152
|
+
if (!stored)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(stored);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
discovered() {
|
|
162
|
+
this.endpoints ??= discover(this.options.issuer, this.fetcher).catch(failure => {
|
|
163
|
+
this.endpoints = null;
|
|
164
|
+
throw failure;
|
|
165
|
+
});
|
|
166
|
+
return this.endpoints;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** What the signed-in user may do in this application, as GrantForge answers it. */
|
|
2
|
+
export interface UserAuthorization {
|
|
3
|
+
application: string;
|
|
4
|
+
accountId: string;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
username: string;
|
|
7
|
+
version: number;
|
|
8
|
+
roles: string[];
|
|
9
|
+
resources: string[];
|
|
10
|
+
permissions: string[];
|
|
11
|
+
computedAt: string;
|
|
12
|
+
}
|
|
13
|
+
/** How the application asks GrantForge about its users. */
|
|
14
|
+
export interface ClientOptions {
|
|
15
|
+
/** GrantForge's address. */
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
/** Returns the user's access token, such as GrantForgeAuth.accessToken; null when nobody is signed in. */
|
|
18
|
+
accessToken: () => Promise<string | null>;
|
|
19
|
+
/** How long an answer is used before it is revalidated with its ETag, in milliseconds; 30 seconds by default. */
|
|
20
|
+
ttl?: number;
|
|
21
|
+
/** The fetch to call GrantForge with. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
/** The current time in milliseconds. */
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Asks GrantForge's open API what the signed-in user may do. The answer is kept for the TTL, then revalidated with
|
|
28
|
+
* If-None-Match, which costs GrantForge a 304 while nothing changed.
|
|
29
|
+
*/
|
|
30
|
+
export declare class GrantForgeClient {
|
|
31
|
+
private readonly options;
|
|
32
|
+
private readonly ttl;
|
|
33
|
+
private readonly fetcher;
|
|
34
|
+
private readonly now;
|
|
35
|
+
private kept;
|
|
36
|
+
private pending;
|
|
37
|
+
constructor(options: ClientOptions);
|
|
38
|
+
/** Returns what the user may do; throws a GrantForgeError when nobody is signed in or GrantForge refuses. */
|
|
39
|
+
authorization(): Promise<UserAuthorization>;
|
|
40
|
+
/** Returns whether the user may call an API of this application. */
|
|
41
|
+
can(permission: string): Promise<boolean>;
|
|
42
|
+
/** Returns whether the user may use a resource of this application, such as a page or a button. */
|
|
43
|
+
hasResource(resource: string): Promise<boolean>;
|
|
44
|
+
/** Forgets the kept answer, as when the user signs out or another user signs in. */
|
|
45
|
+
forget(): void;
|
|
46
|
+
private load;
|
|
47
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
import { errorOf, GrantForgeError } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Asks GrantForge's open API what the signed-in user may do. The answer is kept for the TTL, then revalidated with
|
|
8
|
+
* If-None-Match, which costs GrantForge a 304 while nothing changed.
|
|
9
|
+
*/
|
|
10
|
+
export class GrantForgeClient {
|
|
11
|
+
options;
|
|
12
|
+
ttl;
|
|
13
|
+
fetcher;
|
|
14
|
+
now;
|
|
15
|
+
kept = null;
|
|
16
|
+
pending = null;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.ttl = options.ttl ?? 30_000;
|
|
20
|
+
this.fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
21
|
+
this.now = options.now ?? (() => Date.now());
|
|
22
|
+
}
|
|
23
|
+
/** Returns what the user may do; throws a GrantForgeError when nobody is signed in or GrantForge refuses. */
|
|
24
|
+
authorization() {
|
|
25
|
+
this.pending ??= this.load().finally(() => { this.pending = null; });
|
|
26
|
+
return this.pending;
|
|
27
|
+
}
|
|
28
|
+
/** Returns whether the user may call an API of this application. */
|
|
29
|
+
async can(permission) {
|
|
30
|
+
return (await this.authorization()).permissions.includes(permission);
|
|
31
|
+
}
|
|
32
|
+
/** Returns whether the user may use a resource of this application, such as a page or a button. */
|
|
33
|
+
async hasResource(resource) {
|
|
34
|
+
return (await this.authorization()).resources.includes(resource);
|
|
35
|
+
}
|
|
36
|
+
/** Forgets the kept answer, as when the user signs out or another user signs in. */
|
|
37
|
+
forget() {
|
|
38
|
+
this.kept = null;
|
|
39
|
+
}
|
|
40
|
+
async load() {
|
|
41
|
+
const token = await this.options.accessToken();
|
|
42
|
+
if (!token) {
|
|
43
|
+
this.kept = null;
|
|
44
|
+
throw new GrantForgeError('unauthenticated', 'Nobody is signed in');
|
|
45
|
+
}
|
|
46
|
+
const kept = this.kept?.token === token ? this.kept : null;
|
|
47
|
+
if (kept && kept.checkedAt + this.ttl > this.now())
|
|
48
|
+
return kept.answer;
|
|
49
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
|
50
|
+
if (kept)
|
|
51
|
+
headers['If-None-Match'] = kept.etag;
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await this.fetcher(`${this.options.baseUrl.replace(/\/+$/, '')}/api/v1/open/me/authorization`, { headers });
|
|
55
|
+
}
|
|
56
|
+
catch (failure) {
|
|
57
|
+
throw new GrantForgeError('unavailable', `GrantForge did not answer: ${String(failure)}`);
|
|
58
|
+
}
|
|
59
|
+
if (response.status === 304 && kept) {
|
|
60
|
+
this.kept = { ...kept, checkedAt: this.now() };
|
|
61
|
+
return kept.answer;
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
if (response.status === 401 || response.status === 403)
|
|
65
|
+
this.kept = null;
|
|
66
|
+
throw errorOf(response, 'The open API');
|
|
67
|
+
}
|
|
68
|
+
const answer = await response.json();
|
|
69
|
+
this.kept = { token, answer, etag: response.headers.get('ETag') ?? `"${answer.version}"`, checkedAt: this.now() };
|
|
70
|
+
return answer;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The endpoints of GrantForge's authorization server, from its OpenID Connect discovery document. */
|
|
2
|
+
export interface Endpoints {
|
|
3
|
+
issuer: string;
|
|
4
|
+
authorization: string;
|
|
5
|
+
token: string;
|
|
6
|
+
revocation: string | null;
|
|
7
|
+
userInfo: string | null;
|
|
8
|
+
endSession: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** Reads the discovery document of an issuer. */
|
|
11
|
+
export declare function discover(issuer: string, fetcher?: typeof fetch): Promise<Endpoints>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
import { errorOf, GrantForgeError } from './errors.js';
|
|
6
|
+
/** Reads the discovery document of an issuer. */
|
|
7
|
+
export async function discover(issuer, fetcher = fetch) {
|
|
8
|
+
const base = issuer.replace(/\/+$/, '');
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetcher(`${base}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } });
|
|
12
|
+
}
|
|
13
|
+
catch (failure) {
|
|
14
|
+
throw new GrantForgeError('unavailable', `GrantForge at ${base} did not answer: ${String(failure)}`);
|
|
15
|
+
}
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw errorOf(response, 'The discovery document');
|
|
18
|
+
const document = await response.json();
|
|
19
|
+
const text = (name) => typeof document[name] === 'string' ? document[name] : null;
|
|
20
|
+
const authorization = text('authorization_endpoint'), token = text('token_endpoint');
|
|
21
|
+
if (!authorization || !token)
|
|
22
|
+
throw new GrantForgeError('unavailable', 'The discovery document lacks the authorization or token endpoint');
|
|
23
|
+
return { issuer: text('issuer') ?? base, authorization, token, revocation: text('revocation_endpoint'), userInfo: text('userinfo_endpoint'),
|
|
24
|
+
endSession: text('end_session_endpoint') };
|
|
25
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Why GrantForge could not, or would not, answer. */
|
|
2
|
+
export type GrantForgeReason = 'unauthenticated' | 'forbidden' | 'unavailable' | 'invalid';
|
|
3
|
+
/** A refusal or failure of GrantForge, with the HTTP status when there was one. */
|
|
4
|
+
export declare class GrantForgeError extends Error {
|
|
5
|
+
readonly reason: GrantForgeReason;
|
|
6
|
+
readonly status: number;
|
|
7
|
+
constructor(reason: GrantForgeReason, message: string, status?: number);
|
|
8
|
+
}
|
|
9
|
+
/** Turns an unsuccessful response into an error: 401 needs a new sign-in, 403 a permission, anything else waits. */
|
|
10
|
+
export declare function errorOf(response: Response, what: string): GrantForgeError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
/** A refusal or failure of GrantForge, with the HTTP status when there was one. */
|
|
6
|
+
export class GrantForgeError extends Error {
|
|
7
|
+
reason;
|
|
8
|
+
status;
|
|
9
|
+
constructor(reason, message, status = 0) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = 'GrantForgeError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Turns an unsuccessful response into an error: 401 needs a new sign-in, 403 a permission, anything else waits. */
|
|
17
|
+
export function errorOf(response, what) {
|
|
18
|
+
const reason = response.status === 401 ? 'unauthenticated' : response.status === 403 ? 'forbidden'
|
|
19
|
+
: response.status === 400 ? 'invalid' : 'unavailable';
|
|
20
|
+
return new GrantForgeError(reason, `${what} answered ${response.status}`, response.status);
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { GrantForgeAuth, type AuthOptions, type Tokens } from './auth.js';
|
|
2
|
+
export { GrantForgeClient, type ClientOptions, type UserAuthorization } from './client.js';
|
|
3
|
+
export { discover, type Endpoints } from './discovery.js';
|
|
4
|
+
export { GrantForgeError, type GrantForgeReason } from './errors.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
export { GrantForgeAuth } from './auth.js';
|
|
6
|
+
export { GrantForgeClient } from './client.js';
|
|
7
|
+
export { discover } from './discovery.js';
|
|
8
|
+
export { GrantForgeError } from './errors.js';
|
package/dist/pkce.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Random URL-safe text of 32 bytes, for PKCE verifiers, states and nonces. */
|
|
2
|
+
export declare function randomToken(): string;
|
|
3
|
+
/** The S256 challenge of a PKCE verifier. */
|
|
4
|
+
export declare function challengeOf(verifier: string): Promise<string>;
|
|
5
|
+
/** Base64url without padding, as OAuth uses it. */
|
|
6
|
+
export declare function base64Url(bytes: Uint8Array): string;
|
|
7
|
+
/** The claims of a JWT, read without checking its signature: the token came from GrantForge over TLS. */
|
|
8
|
+
export declare function claimsOf(jwt: string): Record<string, unknown>;
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
/** Random URL-safe text of 32 bytes, for PKCE verifiers, states and nonces. */
|
|
6
|
+
export function randomToken() {
|
|
7
|
+
const bytes = new Uint8Array(32);
|
|
8
|
+
crypto.getRandomValues(bytes);
|
|
9
|
+
return base64Url(bytes);
|
|
10
|
+
}
|
|
11
|
+
/** The S256 challenge of a PKCE verifier. */
|
|
12
|
+
export async function challengeOf(verifier) {
|
|
13
|
+
// Verifiers are ASCII (RFC 7636); bytes of this realm, as some environments mix realms of typed arrays.
|
|
14
|
+
const digest = await crypto.subtle.digest('SHA-256', Uint8Array.from(verifier, character => character.charCodeAt(0)));
|
|
15
|
+
return base64Url(new Uint8Array(digest));
|
|
16
|
+
}
|
|
17
|
+
/** Base64url without padding, as OAuth uses it. */
|
|
18
|
+
export function base64Url(bytes) {
|
|
19
|
+
let binary = '';
|
|
20
|
+
for (const byte of bytes)
|
|
21
|
+
binary += String.fromCharCode(byte);
|
|
22
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
23
|
+
}
|
|
24
|
+
/** The claims of a JWT, read without checking its signature: the token came from GrantForge over TLS. */
|
|
25
|
+
export function claimsOf(jwt) {
|
|
26
|
+
const payload = jwt.split('.')[1];
|
|
27
|
+
if (!payload)
|
|
28
|
+
throw new Error('not a JWT');
|
|
29
|
+
const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
30
|
+
const binary = atob(padded);
|
|
31
|
+
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
32
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
33
|
+
}
|
package/dist/vue.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type App, type ShallowRef } from 'vue';
|
|
2
|
+
import type { GrantForgeClient, UserAuthorization } from './client.js';
|
|
3
|
+
import { GrantForgeError } from './errors.js';
|
|
4
|
+
/** The signed-in user's permissions, reactive. */
|
|
5
|
+
export interface GrantForgeState {
|
|
6
|
+
/** What the user may do, or null before it is known and when nobody is signed in. */
|
|
7
|
+
authorization: ShallowRef<UserAuthorization | null>;
|
|
8
|
+
/** Why the last attempt to know failed, or null. */
|
|
9
|
+
error: ShallowRef<GrantForgeError | null>;
|
|
10
|
+
/** Asks GrantForge again, as after signing in or out. */
|
|
11
|
+
refresh: () => Promise<void>;
|
|
12
|
+
/** Returns whether the user may call an API; false until known. */
|
|
13
|
+
can: (permission: string) => boolean;
|
|
14
|
+
/** Returns whether the user may use a resource, such as a button; false until known. */
|
|
15
|
+
hasResource: (resource: string) => boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The Vue plugin: provides the user's permissions (useGrantForge) and the directives v-permission and v-resource, which
|
|
19
|
+
* hide an element unless the user holds all the codes given (a code or a list of codes); with the .disable modifier they
|
|
20
|
+
* disable it instead. Hiding is a convenience: the application's APIs must check permissions too.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createGrantForge(client: GrantForgeClient): {
|
|
23
|
+
state: GrantForgeState;
|
|
24
|
+
install(app: App): void;
|
|
25
|
+
};
|
|
26
|
+
/** Returns the user's permissions inside components of an application with the plugin installed. */
|
|
27
|
+
export declare function useGrantForge(): GrantForgeState;
|
package/dist/vue.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Copyright (c) 2026 devlive-community/grantforge
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the MIT License. See the LICENSE file in the
|
|
4
|
+
// project root for full license text.
|
|
5
|
+
import { inject, shallowRef, watchEffect } from 'vue';
|
|
6
|
+
import { GrantForgeError } from './errors.js';
|
|
7
|
+
const KEY = Symbol('grantforge');
|
|
8
|
+
const HIDDEN = Symbol('grantforge.display');
|
|
9
|
+
const STOP = Symbol('grantforge.stop');
|
|
10
|
+
/**
|
|
11
|
+
* The Vue plugin: provides the user's permissions (useGrantForge) and the directives v-permission and v-resource, which
|
|
12
|
+
* hide an element unless the user holds all the codes given (a code or a list of codes); with the .disable modifier they
|
|
13
|
+
* disable it instead. Hiding is a convenience: the application's APIs must check permissions too.
|
|
14
|
+
*/
|
|
15
|
+
export function createGrantForge(client) {
|
|
16
|
+
const authorization = shallowRef(null);
|
|
17
|
+
const error = shallowRef(null);
|
|
18
|
+
const state = {
|
|
19
|
+
authorization,
|
|
20
|
+
error,
|
|
21
|
+
async refresh() {
|
|
22
|
+
try {
|
|
23
|
+
authorization.value = await client.authorization();
|
|
24
|
+
error.value = null;
|
|
25
|
+
}
|
|
26
|
+
catch (failure) {
|
|
27
|
+
authorization.value = null;
|
|
28
|
+
error.value = failure instanceof GrantForgeError ? failure : new GrantForgeError('unavailable', String(failure));
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
can: permission => authorization.value?.permissions.includes(permission) ?? false,
|
|
32
|
+
hasResource: resource => authorization.value?.resources.includes(resource) ?? false,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
state,
|
|
36
|
+
install(app) {
|
|
37
|
+
app.provide(KEY, state);
|
|
38
|
+
app.directive('permission', guard(codes => codes.every(state.can)));
|
|
39
|
+
app.directive('resource', guard(codes => codes.every(state.hasResource)));
|
|
40
|
+
void state.refresh();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Returns the user's permissions inside components of an application with the plugin installed. */
|
|
45
|
+
export function useGrantForge() {
|
|
46
|
+
const state = inject(KEY, null);
|
|
47
|
+
if (!state)
|
|
48
|
+
throw new Error('useGrantForge needs the plugin of createGrantForge installed');
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
function guard(allowed) {
|
|
52
|
+
return {
|
|
53
|
+
mounted(element, binding) {
|
|
54
|
+
const target = element;
|
|
55
|
+
target[HIDDEN] = target.style.display;
|
|
56
|
+
target[STOP] = watchEffect(() => apply(target, allowed(codesOf(binding.value)), binding.modifiers.disable === true));
|
|
57
|
+
},
|
|
58
|
+
updated(element, binding) {
|
|
59
|
+
const target = element;
|
|
60
|
+
target[STOP]?.();
|
|
61
|
+
target[STOP] = watchEffect(() => apply(target, allowed(codesOf(binding.value)), binding.modifiers.disable === true));
|
|
62
|
+
},
|
|
63
|
+
unmounted(element) {
|
|
64
|
+
element[STOP]?.();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function apply(element, allowed, disable) {
|
|
69
|
+
if (disable) {
|
|
70
|
+
if (allowed)
|
|
71
|
+
element.removeAttribute('disabled');
|
|
72
|
+
else
|
|
73
|
+
element.setAttribute('disabled', '');
|
|
74
|
+
element.setAttribute('aria-disabled', String(!allowed));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
element.style.display = allowed ? element[HIDDEN] ?? '' : 'none';
|
|
78
|
+
}
|
|
79
|
+
function codesOf(value) {
|
|
80
|
+
if (value === undefined)
|
|
81
|
+
return [];
|
|
82
|
+
return Array.isArray(value) ? value : [value];
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grantforge/client",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2026.0.0",
|
|
4
|
+
"description": "Sign users in through GrantForge with PKCE and ask what they may do, with a Vue plugin and directives.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@8.10.2",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.12.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./vue": {
|
|
21
|
+
"types": "./dist/vue.d.ts",
|
|
22
|
+
"default": "./dist/vue.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "eslint . --max-warnings 0",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"vue": "^3.5.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"vue": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.0",
|
|
41
|
+
"@vue/test-utils": "2.5.1",
|
|
42
|
+
"eslint": "10.11.0",
|
|
43
|
+
"globals": "^17.0.0",
|
|
44
|
+
"jsdom": "^29.0.0",
|
|
45
|
+
"typescript": "5.9.3",
|
|
46
|
+
"typescript-eslint": "8.71.0",
|
|
47
|
+
"vitest": "5.0.2",
|
|
48
|
+
"vue": "3.5.43"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/README.md
DELETED