@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.
Files changed (4) hide show
  1. package/README.md +60 -0
  2. package/index.d.ts +14 -0
  3. package/index.js +128 -0
  4. 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
+ };
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@mpudt/age-verification",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "types": "index.d.ts",
6
+ "scripts": {
7
+ "build": "node --check index.js"
8
+ },
9
+ "dependencies": {
10
+ "jwt-decode": "^4.0.0"
11
+ }
12
+ }