@mpudt/age-verification 1.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/README.md +60 -0
- package/index.d.ts +14 -0
- package/index.js +128 -0
- package/package.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# age-verification
|
|
2
|
+
|
|
3
|
+
mGrađani age verification client for web shops. Uses OAuth 2.1 + OIDC with PKCE.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mpudt/age-verification
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### 1. Create the client
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { AgeVerificationClient } from 'age-verification';
|
|
17
|
+
|
|
18
|
+
const client = new AgeVerificationClient({
|
|
19
|
+
issuer: 'https://mgradjani-test.gov.hr/idp',
|
|
20
|
+
clientId: 'your-client-id',
|
|
21
|
+
redirectUri: 'https://your-shop.com/callback',
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 2. Redirect user to authorization
|
|
26
|
+
|
|
27
|
+
On your verify button click, generate the URL and redirect:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const url = await client.createAuthorizationUrl();
|
|
31
|
+
window.location.href = url;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The user will be redirected to mGrađani, scan a QR code with their mobile app, and then redirected back to your `redirectUri` with a `code` in the query string.
|
|
35
|
+
|
|
36
|
+
### 3. Handle the callback and verify age
|
|
37
|
+
|
|
38
|
+
On your callback page, read the `code` from the URL and call `isAgeVerified`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const code = new URLSearchParams(window.location.search).get('code');
|
|
42
|
+
const verified = await client.isAgeVerified(code);
|
|
43
|
+
|
|
44
|
+
if (verified) {
|
|
45
|
+
// user is of legal drinking age
|
|
46
|
+
} else {
|
|
47
|
+
// user is not verified
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`isAgeVerified` exchanges the code for a token and returns `true` or `false`.
|
|
52
|
+
|
|
53
|
+
## Config
|
|
54
|
+
|
|
55
|
+
| Option | Required | Default | Description |
|
|
56
|
+
|---------------|----------|---------------|------------------------------------|
|
|
57
|
+
| `issuer` | yes | — | mGrađani IdP base URL |
|
|
58
|
+
| `clientId` | yes | — | Your web shop client ID |
|
|
59
|
+
| `redirectUri` | yes | — | Callback URL after authorization |
|
|
60
|
+
| `scope` | no | `age.alcohol` | OIDC scope to request |
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface AgeVerificationConfig {
|
|
2
|
+
issuer: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
scope?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class AgeVerificationClient {
|
|
9
|
+
constructor(config: AgeVerificationConfig);
|
|
10
|
+
initialize(): Promise<unknown>;
|
|
11
|
+
getDiscoveryDocument(): Promise<unknown>;
|
|
12
|
+
createAuthorizationUrl(): Promise<string>;
|
|
13
|
+
isAgeVerified(code: string): Promise<boolean>;
|
|
14
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const { jwtDecode } = require("jwt-decode");
|
|
2
|
+
|
|
3
|
+
class AgeVerificationClient {
|
|
4
|
+
constructor({ issuer, clientId, redirectUri, scope = "age.alcohol" }) {
|
|
5
|
+
if (!issuer) throw new Error("issuer is required");
|
|
6
|
+
if (!clientId) throw new Error("clientId is required");
|
|
7
|
+
if (!redirectUri) throw new Error("redirectUri is required");
|
|
8
|
+
|
|
9
|
+
this.issuer = issuer.replace(/\/$/, "");
|
|
10
|
+
this.clientId = clientId;
|
|
11
|
+
this.redirectUri = redirectUri;
|
|
12
|
+
this.scope = scope;
|
|
13
|
+
|
|
14
|
+
this.discovery = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async initialize() {
|
|
18
|
+
const response = await fetch(
|
|
19
|
+
`${this.issuer}/.well-known/openid-configuration`,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error("Unable to load OpenID configuration");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.discovery = await response.json();
|
|
27
|
+
|
|
28
|
+
return this.discovery;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getDiscoveryDocument() {
|
|
32
|
+
if (!this.discovery) {
|
|
33
|
+
await this.initialize();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return this.discovery;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async createAuthorizationUrl() {
|
|
40
|
+
const discovery = await this.getDiscoveryDocument();
|
|
41
|
+
|
|
42
|
+
const state = randomString();
|
|
43
|
+
const nonce = randomString();
|
|
44
|
+
const verifier = randomString(64);
|
|
45
|
+
const challenge = await sha256(verifier);
|
|
46
|
+
|
|
47
|
+
sessionStorage.setItem("pkce_verifier", verifier);
|
|
48
|
+
sessionStorage.setItem("oidc_state", state);
|
|
49
|
+
sessionStorage.setItem("oidc_nonce", nonce);
|
|
50
|
+
|
|
51
|
+
const params = new URLSearchParams({
|
|
52
|
+
response_type: "code",
|
|
53
|
+
client_id: this.clientId,
|
|
54
|
+
redirect_uri: this.redirectUri,
|
|
55
|
+
scope: this.scope,
|
|
56
|
+
state,
|
|
57
|
+
nonce,
|
|
58
|
+
code_challenge: challenge,
|
|
59
|
+
code_challenge_method: "S256",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return `${discovery.authorization_endpoint}?${params.toString()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async isAgeVerified(code) {
|
|
66
|
+
if (!code) {
|
|
67
|
+
throw new Error("authorization code is required");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const discovery = await this.getDiscoveryDocument();
|
|
71
|
+
|
|
72
|
+
const verifier = sessionStorage.getItem("pkce_verifier");
|
|
73
|
+
|
|
74
|
+
if (!verifier) {
|
|
75
|
+
throw new Error("Missing PKCE verifier");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body = new URLSearchParams({
|
|
79
|
+
grant_type: "authorization_code",
|
|
80
|
+
code,
|
|
81
|
+
client_id: this.clientId,
|
|
82
|
+
redirect_uri: this.redirectUri,
|
|
83
|
+
code_verifier: verifier,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
90
|
+
},
|
|
91
|
+
body,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error("Token exchange failed");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tokens = await response.json();
|
|
99
|
+
|
|
100
|
+
const isVerified =
|
|
101
|
+
tokens.id_token && jwtDecode(tokens.id_token)["age.alcohol"];
|
|
102
|
+
|
|
103
|
+
return isVerified;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function randomString(length = 32) {
|
|
108
|
+
const bytes = new Uint8Array(length);
|
|
109
|
+
crypto.getRandomValues(bytes);
|
|
110
|
+
return btoa(String.fromCharCode(...bytes))
|
|
111
|
+
.replace(/\+/g, "-")
|
|
112
|
+
.replace(/\//g, "_")
|
|
113
|
+
.replace(/=+$/, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function sha256(value) {
|
|
117
|
+
const encoded = new TextEncoder().encode(value);
|
|
118
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
|
|
119
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
120
|
+
return btoa(String.fromCharCode(...hashArray))
|
|
121
|
+
.replace(/\+/g, "-")
|
|
122
|
+
.replace(/\//g, "_")
|
|
123
|
+
.replace(/=+$/, "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
AgeVerificationClient,
|
|
128
|
+
};
|