edge runtime で firebase auth verifyIdToken したい

Next.js Middleware で firebase auth の idToken の検証を行いたいが、firebase-admin sdk が edge runtime では使えない...
自前で verifyIdToken を作ってみる。

公式 Doc
こちらに自前で用意する場合の説明があるので読む

Header の検証
Verify the ID token's header conforms to the following constraints:
ID Token Header Claims
alg Algorithm "RS256"
kid Key ID Must correspond to one of the public keys listed at https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com
-
alg
が RS256 -
kid
が google が用意した公開鍵であること (https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com で確認)

payload の検証
Verify the ID token's payload conforms to the following constraints:
exp Expiration time Must be in the future. The time is measured in seconds since the UNIX epoch.
iat Issued-at time Must be in the past. The time is measured in seconds since the UNIX epoch.
aud Audience Must be your Firebase project ID, the unique identifier for your Firebase project, which can be found in the URL of that project's console.
iss Issuer Must be "https://securetoken.google.com/<projectId>", where <projectId> is the same project ID used for aud above.
sub Subject Must be a non-empty string and must be the uid of the user or device.
auth_time Authentication time Must be in the past. The time when the user authenticated.
-
exp
が未来であること -
iat
が過去であること -
aud
が自身の firebase projectId であること -
iss
が "https://securetoken.google.com/<projectId>" であること -
sub
に文字列が入っている (= uid) -
auth_time
が過去であること

signature の検証
Finally, ensure that the ID token was signed by the private key corresponding to the token's kid claim. Grab the public key from https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com and use a JWT library to verify the signature. Use the value of max-age in the Cache-Control header of the response from that endpoint to know when to refresh the public keys.
-
kid
に対応する秘密鍵で sign されているかを検証する。公開鍵は "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" から取得。 - 上記公開鍵の endpoint の Cache-Control heaader max-age の値を元に適宜正しい public key を利用する。

firebase の公開鍵について
下記 URL から公開鍵を取得とのことだが、このデータは一体何なのか調べる。
実際のデータ
{
"a238dd04cbaa580b304c881e1c008ec28fbbaddc": "-----BEGIN CERTIFICATE-----\nMIIDHTCCAgWgAwIBAgIJAObMlT0V930JMA0GCSqGSIb3DQEBBQUAMDExLzAtBgNV\nBAMMJnNlY3VyZXRva2VuLnN5c3RlbS5nc2VydmljZWFjY291bnQuY29tMB4XDTI0\nMDQyMjA3MzIyMFoXDTI0MDUwODE5NDcyMFowMTEvMC0GA1UEAwwmc2VjdXJldG9r\nZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQDdtOxC3HFjVHEZkenoiDQKMrJOzUbzlS7j0qOWfmY4XMst\nxS9V/9ToiBg+5l2YozafcXaWppNIDaMLax3Zo0/Pi4qduB2gT6pBgawBuuu0e7jo\nB/D6+cokfBLzzSK1xBcFNhQRHbDPLtfy6iaYBlBl7Mxpe9t2hX7DOlR1LHpZ/Zhu\nP1j10V3bJZIg4Ot5scyVz3WExIXJGQyc3qTMFumOqKJPHFyPIdZXytaehtlsgmdB\nrHvkjVU9Hde4pUI7nOHExZvyeKzn8WvGziIiAaBk7p9t4Vsrjpsr8XB6iX95e6z/\nr9cki1/ndvGPQF1wGFXGQsWRwsfBZjQC9lf2BsYzAgMBAAGjODA2MAwGA1UdEwEB\n/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMA0G\nCSqGSIb3DQEBBQUAA4IBAQBU4xTBbynw/6qiLRIkc7veLNs08IUVOiaedSkGptXf\niQla6wmzRJOi9CXgMQQuJnJfQGdoZ8sUJLYlPj2SrGKZimkIW8nsu4YYyGzKtyuS\n9fslFjHPrLzhJTRs0w3hnR+1EwXDmSHZDtjSyEA0KBMxWi1m5SOtZEboq4xsN8BC\ndGvkawDGD0NtRWq0bRhN1p+m1hdIWwpxFdP6nKPZRMmxJ+H54UZXZjNYZcpxSAQj\n1EDqb/FWavYNW4Z34NZ1avQeO0PfwzTbe1TfO8iy0qURtmD8zYnp6mAn/ANtG7E2\nkkl2mWp7ONyCDYV4rmCHgcye7UnwDio3m7Ns6W15LDmV\n-----END CERTIFICATE-----\n",
"e2b22fd47ede682f68faccfe7c4cf5b11b12b54b": "-----BEGIN CERTIFICATE-----\nMIIDHTCCAgWgAwIBAgIJAINORaFSRUgEMA0GCSqGSIb3DQEBBQUAMDExLzAtBgNV\nBAMMJnNlY3VyZXRva2VuLnN5c3RlbS5nc2VydmljZWFjY291bnQuY29tMB4XDTI0\nMDUwODA3MzIyMloXDTI0MDUyNDE5NDcyMlowMTEvMC0GA1UEAwwmc2VjdXJldG9r\nZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQClR/jplqJnYpXpiLke5EqbbRL0+wxL0JC87YsIUB5zXuEL\nfxokjCl2lMfDc0w+L6PjG8CZt3W6S8M5UMTglNS/91oz4WiqaEc4hKaknYfSk8vs\nxnA3WAs/0OWulJc1M2B5ikuiFXi1lemLS+sOFe63ukuzXuQqYc2Z8uf/QUEQwvTn\n1VK2Ij4dDNYcygnTCgE8F+j5+SE/+C6Ik6B+xkG1EICPAO3FFCB8KxuDL7s4HKzY\nIzrp/Vf/pTv9DMPQykTgZ5crrtAQYMyyrOY50lYQgFc0V44Dt9OVh/pIpo9cHXO6\n9QY1EMZegRW1LINQDlEyHQaGK0CmkFqQoul8Y7cnAgMBAAGjODA2MAwGA1UdEwEB\n/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMA0G\nCSqGSIb3DQEBBQUAA4IBAQBZAFCH5zX+Bk1uwnh5duuj20HsvCSv5/OEBSEJyRfw\n6IHajwMQF6yxEXGcpcUGweUoCqDsqYiu/44/ZzTPILAU6rPTbznUp47miWWxGBu+\nnqFdv9OfrAKW+tTBhzaNskBrYI99uLPs+CbxRL4OjScZt8LhMKMg9nAzMOIgU+Z1\nqzQzl5WUZNMXvVjBCaSKVEn/GtdGTr+mGi1LOQkYAKs7lDl7656biGkY4ezfUiOi\nCWx70CyDv90Jy6V+uN+i3Fb1RHJYstavuRbZMJ4GxDrN6Fr3Z8xPmnpSJHrnL5Xl\nILs7iZPe6WcYlYkh8lO3oCXdOTSCVM/hL1dXqxL6Mqts\n-----END CERTIFICATE-----\n",
"7602712682df99cfb891aa037d73bcc6a3970084": "-----BEGIN CERTIFICATE-----\nMIIDHTCCAgWgAwIBAgIJALeM3DBeoI3wMA0GCSqGSIb3DQEBBQUAMDExLzAtBgNV\nBAMMJnNlY3VyZXRva2VuLnN5c3RlbS5nc2VydmljZWFjY291bnQuY29tMB4XDTI0\nMDQzMDA3MzIyMVoXDTI0MDUxNjE5NDcyMVowMTEvMC0GA1UEAwwmc2VjdXJldG9r\nZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wggEiMA0GCSqGSIb3DQEBAQUA\nA4IBDwAwggEKAoIBAQCZ5H6KYWuP1+SwCsN9tQXsD4JXii7FEJr/NGoQnHePBobr\nOzHaSdyxov7o3XqtouXmRDetVpANdph2r5+rTFY1231KehQF9HosYDA4zT/Ph32Z\n+kpS9Xlkg+515lowQtYGnwlAmnHirivTgUmrHR7GaVVOo7K5erD1tbFiIjTgtHNR\njlxVS786WEdvOkVodJQcKX5/5FDlI01AAbnbLf+iKpCq/bXNGFQI/6r47TTo9qEm\nUAHoPaqW2LceGpH1qqoBoBRfsS4qaWbAxsHjs0cur4x4Ai1c+iJbFNHQfbis/BzM\n5d5DLDFV4n3ZPie03aJoonWbiJX1eTW1No4XyozfAgMBAAGjODA2MAwGA1UdEwEB\n/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMA0G\nCSqGSIb3DQEBBQUAA4IBAQBGERUt+83Ar/OjpwpG9n1hsgM5X5TBrZXMPLpzlr0Y\nDOSB3svrvwBOcJftddUIStJKaEaFwuK+N6TuxtYbcE8tBF7QG1H1M7OdIb8j1o4j\naGggP9ziXiFgRHBADd8o4gHgeBygfZQUU73XHDu1jSzNsUELF0mUt5ffKxSoRtq2\ne1ng74n9sBmExN7HNW8DnyXyF21AnFeCqY3ttTY4KttsGKIXJB1PKXZ31wbTTeVH\njmn+QRC6co2ENNCgCtWr1GiBrgkve8HbtR1qbSDnpBiGAdH+yxBWCRNTEEPW4E7b\nZTGhgbFh1YNFf/+ihvomrfCeCdfwbQEkvs6hhQAI4nTC\n-----END CERTIFICATE-----\n"
}

X.509 Certificates の map
Key Identifier (kid) をkey にもち、X.509 certificate (公開鍵) を value にもつ JSON。
x.509 certificate とは?

中身見てみる
openssl を使って x.509 certificate を decode できるみたいなのでやってみる。
openssl x509 -in certificate.crt -text -noout
decode した結果
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
e6:cc:95:3d:15:f7:7d:09
Signature Algorithm: sha1WithRSAEncryption
Issuer: CN=securetoken.system.gserviceaccount.com
Validity
Not Before: Apr 22 07:32:20 2024 GMT
Not After : May 8 19:47:20 2024 GMT
Subject: CN=securetoken.system.gserviceaccount.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:dd:b4:ec:42:dc:71:63:54:71:19:91:e9:e8:88:
34:0a:32:b2:4e:cd:46:f3:95:2e:e3:d2:a3:96:7e:
66:38:5c:cb:2d:c5:2f:55:ff:d4:e8:88:18:3e:e6:
5d:98:a3:36:9f:71:76:96:a6:93:48:0d:a3:0b:6b:
1d:d9:a3:4f:cf:8b:8a:9d:b8:1d:a0:4f:aa:41:81:
ac:01:ba:eb:b4:7b:b8:e8:07:f0:fa:f9:ca:24:7c:
12:f3:cd:22:b5:c4:17:05:36:14:11:1d:b0:cf:2e:
d7:f2:ea:26:98:06:50:65:ec:cc:69:7b:db:76:85:
7e:c3:3a:54:75:2c:7a:59:fd:98:6e:3f:58:f5:d1:
5d:db:25:92:20:e0:eb:79:b1:cc:95:cf:75:84:c4:
85:c9:19:0c:9c:de:a4:cc:16:e9:8e:a8:a2:4f:1c:
5c:8f:21:d6:57:ca:d6:9e:86:d9:6c:82:67:41:ac:
7b:e4:8d:55:3d:1d:d7:b8:a5:42:3b:9c:e1:c4:c5:
9b:f2:78:ac:e7:f1:6b:c6:ce:22:22:01:a0:64:ee:
9f:6d:e1:5b:2b:8e:9b:2b:f1:70:7a:89:7f:79:7b:
ac:ff:af:d7:24:8b:5f:e7:76:f1:8f:40:5d:70:18:
55:c6:42:c5:91:c2:c7:c1:66:34:02:f6:57:f6:06:
c6:33
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage: critical
TLS Web Client Authentication
Signature Algorithm: sha1WithRSAEncryption
Signature Value:
54:e3:14:c1:6f:29:f0:ff:aa:a2:2d:12:24:73:bb:de:2c:db:
34:f0:85:15:3a:26:9e:75:29:06:a6:d5:df:89:09:5a:eb:09:
b3:44:93:a2:f4:25:e0:31:04:2e:26:72:5f:40:67:68:67:cb:
14:24:b6:25:3e:3d:92:ac:62:99:8a:69:08:5b:c9:ec:bb:86:
18:c8:6c:ca:b7:2b:92:f5:fb:25:16:31:cf:ac:bc:e1:25:34:
6c:d3:0d:e1:9d:1f:b5:13:05:c3:99:21:d9:0e:d8:d2:c8:40:
34:28:13:31:5a:2d:66:e5:23:ad:64:46:e8:ab:8c:6c:37:c0:
42:74:6b:e4:6b:00:c6:0f:43:6d:45:6a:b4:6d:18:4d:d6:9f:
a6:d6:17:48:5b:0a:71:15:d3:fa:9c:a3:d9:44:c9:b1:27:e1:
f9:e1:46:57:66:33:58:65:ca:71:48:04:23:d4:40:ea:6f:f1:
56:6a:f6:0d:5b:86:77:e0:d6:75:6a:f4:1e:3b:43:df:c3:34:
db:7b:54:df:3b:c8:b2:d2:a5:11:b6:60:fc:cd:89:e9:ea:60:
27:fc:03:6d:1b:b1:36:92:49:76:99:6a:7b:38:dc:82:0d:85:
78:ae:60:87:81:cc:9e:ed:49:f0:0e:2a:37:9b:b3:6c:e9:6d:
79:2c:39:95

firebase-admin sdk 内部実装みる

jsonwebtoken 使ってる

use a JWT library to verify the signature. Use the value of max-age in the Cache-Control header of the response from that endpoint to know when to refresh the public keys.
Cache-Control headerr に基づいた public key の cache についてはこの辺でやってるっぽい↓

payload の検証処理
ここでやってる↓

firebase-admin の verifyIdToken 実装の大まかなフロー
- jsonwebtoken で jwt decode
- header, payload の検証
- signature の検証

実装する
jose を使って実装していく

x506 certificates 取得
一旦、Cache-Control max-age を利用した cache については無視する。ただ endpoint url から json を fetch するだけ。
const GOOGLE_PUBLIC_KEYS_URL =
'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
const fetchX509CertificateMap = async () => {
const response = await fetch(GOOGLE_PUBLIC_KEYS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch public keys: ${response.statusText}`);
}
const certificateMap = await response.json();
return certificateMap as Record<string, string>;
};

publicKey 取得
jose の x506 certificate 用の util を利用して、x506 certificate (pem string) から publicKey を取得。algorithm は 'RS256'。
const publicKey = await jose.importX509(pem, 'RS256')
publicKey は KeyLike
型の object。
publicKey を console.log したもの↓
CryptoKey {
type: "public",
extractable: false,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: {
name: "SHA-256"
}
},
usages: [ "verify" ]
}

kid を header から取得
decodeProtecteddHeaader
で JWT の Header を decode
const protectedHeader = jose.decodeProtectedHeader(token)
console.log(protectedHeader.kid)

JWT singature の検証
jwtVerify
を使う。一旦 signature の検証のみで、claims ごとの細かい validation は置いておく。
const result = await jose.jwtVerify(exampleToken, publicKey, {
algorithms: [ALGORITHM],
});

JWT payload の検証
payload について、以下のチェックをする。
- exp が未来であること
- iat が過去であること
- aud が自身の firebase projectId であること
- iss が "https://securetoken.google.com/<projectId>" であること
- sub に文字列が入っている (= uid)
- auth_time が過去であること
options 確認
jwtVerify
の options で対応できるものもありそうなので確認↓
audience
audience
• Optional audience: string | string[]
Expected JWT "aud" (Audience) Claim value(s).
audience
option で期待する値を指定可能。
issuer
issuer
• Optional issuer: string | string[]
Expected JWT "iss" (Issuer) Claim value(s).
issuer
option で期待する値を指定可能。
maxTokenAge
maxTokenAge
• Optional maxTokenAge: string | number
Maximum time elapsed (in seconds) from the JWT "iat" (Issued At) Claim value.
maxTokenAge
option で iat からの経過時間を指定可能。
requiredClaims
requiredClaims
• Optional requiredClaims: string[]
Array of required Claim Names that must be present in the JWT Claims Set. Default is that: if the issuer option is set, then "iss" must be present; if the audience option is set, then "aud" must be present; if the subject option is set, then "sub" must be present; if the maxTokenAge option is set, then "iat" must be present.
必須の claims を指定可能。
option に応じて、デフォルトで必須の claims が変わる↓
- issuer option があったら iss 必須
- audience option があったら aud 必須
- subject option があったら sub 必須
- maxTokenAge option があったら iat 必須
requiredClaims
と上記デフォルト claims は merge されるので、option の指定の結果必須になっった claims は requiredClaims
に含める必要ない。
exp の検証について
jwtVerify
内部で未来であることのチェックはしてるので別途指定する必要なし。
iat の検証について
jwtVerify
内部で過去であることのチェックはしていない。値が数値であるかのチェックのみ。過去であることを保証するためには maxTokenAge
option を指定するのが良さげ。
maxTokenAge の値は何が良い?
Firebase ID tokens are short lived and last for an hour
ID token の有効期限が1時間なので、それに合わせる形が良さげ。
options の指定まとめ
以下 optios の指定で以下4点は検証OK
✅ exp が未来であること
✅ iat が過去であること
✅ aud が自身の firebase projectId であること
✅ iss が "https://securetoken.google.com/<projectId>" であること
const result = await jose.jwtVerify<TokenPayload>(exampleToken, publicKey, {
algorithms: ['RS256'],
audience: FIREBASE_PROJECT_ID,
issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`,
maxTokenAge: 60 * 60, // 1hour in seconds
requiredClaims: ['exp', 'sub', 'auth_time'],
});
残りの paylod 検証
あとはこの二つを検証すればOK
✅ sub に文字列が入っている (= uid)
✅ auth_time が過去であること
if (!result.payload.sub) {
throw new Error('sub is empty');
}
if (result.payload.auth_time >= Date.now()) {
throw new Error('auth_time is in the future');
}

payload の検証コードまとめ
const result = await jose.jwtVerify<TokenPayload>(exampleToken, publicKey, {
algorithms: ['RS256'],
audience: FIREBASE_PROJECT_ID,
issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`,
maxTokenAge: 60 * 60, // 1hour in seconds
requiredClaims: ['exp', 'sub', 'auth_time'],
});
if (!result.payload.sub) {
throw new Error('sub is empty');
}
if (result.payload.auth_time >= Date.now()) {
throw new Error('auth_time is in the future');
}

sub の値を uid として使えるようにする
firebase-admin の DecodedIdToken には uid が存在するが、この値は jwt 自体には含まれていない。verifyIdToken 実行時に sub の値を uid として生やしている。
/**
* The `uid` corresponding to the user who the ID token belonged to.
*
* This value is not actually in the JWT token claims itself. It is added as a
* convenience, and is set as the value of the [`sub`](#sub) property.
*/
uid: string;
DecodedIdToken の型
export interface DecodedIdToken {
/**
* The audience for which this token is intended.
*
* This value is a string equal to your Firebase project ID, the unique
* identifier for your Firebase project, which can be found in [your project's
* settings](https://console.firebase.google.com/project/_/settings/general/android:com.random.android).
*/
aud: string;
/**
* Time, in seconds since the Unix epoch, when the end-user authentication
* occurred.
*
* This value is not set when this particular ID token was created, but when the
* user initially logged in to this session. In a single session, the Firebase
* SDKs will refresh a user's ID tokens every hour. Each ID token will have a
* different [`iat`](#iat) value, but the same `auth_time` value.
*/
auth_time: number;
/**
* The email of the user to whom the ID token belongs, if available.
*/
email?: string;
/**
* Whether or not the email of the user to whom the ID token belongs is
* verified, provided the user has an email.
*/
email_verified?: boolean;
/**
* The ID token's expiration time, in seconds since the Unix epoch. That is, the
* time at which this ID token expires and should no longer be considered valid.
*
* The Firebase SDKs transparently refresh ID tokens every hour, issuing a new
* ID token with up to a one hour expiration.
*/
exp: number;
/**
* Information about the sign in event, including which sign in provider was
* used and provider-specific identity details.
*
* This data is provided by the Firebase Authentication service and is a
* reserved claim in the ID token.
*/
firebase: {
/**
* Provider-specific identity details corresponding
* to the provider used to sign in the user.
*/
identities: {
[key: string]: any;
};
/**
* The ID of the provider used to sign in the user.
* One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`,
* `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`,
* `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`,
* or `"custom"`.
*
* Additional Identity Platform provider IDs include `"linkedin.com"`,
* OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."`
* respectively.
*/
sign_in_provider: string;
/**
* The type identifier or `factorId` of the second factor, provided the
* ID token was obtained from a multi-factor authenticated user.
* For phone, this is `"phone"`.
*/
sign_in_second_factor?: string;
/**
* The `uid` of the second factor used to sign in, provided the
* ID token was obtained from a multi-factor authenticated user.
*/
second_factor_identifier?: string;
/**
* The ID of the tenant the user belongs to, if available.
*/
tenant?: string;
[key: string]: any;
};
/**
* The ID token's issued-at time, in seconds since the Unix epoch. That is, the
* time at which this ID token was issued and should start to be considered
* valid.
*
* The Firebase SDKs transparently refresh ID tokens every hour, issuing a new
* ID token with a new issued-at time. If you want to get the time at which the
* user session corresponding to the ID token initially occurred, see the
* [`auth_time`](#auth_time) property.
*/
iat: number;
/**
* The issuer identifier for the issuer of the response.
*
* This value is a URL with the format
* `https://securetoken.google.com/<PROJECT_ID>`, where `<PROJECT_ID>` is the
* same project ID specified in the [`aud`](#aud) property.
*/
iss: string;
/**
* The phone number of the user to whom the ID token belongs, if available.
*/
phone_number?: string;
/**
* The photo URL for the user to whom the ID token belongs, if available.
*/
picture?: string;
/**
* The `uid` corresponding to the user who the ID token belonged to.
*
* As a convenience, this value is copied over to the [`uid`](#uid) property.
*/
sub: string;
/**
* The `uid` corresponding to the user who the ID token belonged to.
*
* This value is not actually in the JWT token claims itself. It is added as a
* convenience, and is set as the value of the [`sub`](#sub) property.
*/
uid: string;
/**
* Other arbitrary claims included in the ID token.
*/
[key: string]: any;
}
今回自作する verifyIdToken も同じ動作になるように uid に sub を入れとく。
return {
...result.payload,
uid: result.payload.sub,
};

最終的なコード
import type { DecodedIdToken } from 'firebase-admin/auth';
import * as jose from 'jose';
const FIREBASE_PROJECT_ID = process.env.FIREBASE_PROJECT_ID as string;
const ALGORITHM = 'RS256';
const GOOGLE_PUBLIC_KEYS_URL =
'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
const fetchX509CertificateMap = async () => {
const response = await fetch(GOOGLE_PUBLIC_KEYS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch public keys: ${response.statusText}`);
}
const certificateMap = await response.json();
return certificateMap as Record<string, string>;
};
const importPublicKey = (pem: string) => {
return jose.importX509(pem, ALGORITHM);
};
type TokenPayload = Pick<
DecodedIdToken,
'exp' | 'iat' | 'aud' | 'iss' | 'sub' | 'auth_time'
>;
export const verifyIdToken = async (
token: string
): Promise<TokenPayload & { uid: string }> => {
const protectedHeader = jose.decodeProtectedHeader(token);
const kid = protectedHeader.kid;
if (!kid) {
throw new Error('No kid found in token header');
}
const certificates = await fetchX509CertificateMap();
const pem = certificates[kid];
if (!pem) {
throw new Error(`No certificate found for kid: ${kid}`);
}
const publicKey = await importPublicKey(pem);
const result = await jose.jwtVerify<TokenPayload>(token, publicKey, {
algorithms: [ALGORITHM],
audience: FIREBASE_PROJECT_ID,
issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`,
maxTokenAge: 60 * 60 * 60, // 1hour in seconds
requiredClaims: ['exp', 'sub', 'auth_time'],
});
if (!result.payload.sub) {
throw new Error('sub is empty');
}
if (result.payload.auth_time >= Date.now()) {
throw new Error('auth_time is in the future');
}
return {
...result.payload,
uid: result.payload.sub,
};
};

動作確認
実際に firebase auth client-sdk で作成した idToken を server actions に渡して、decode できるか確認してみる。
'use server';
import { verifyIdToken } from '@/lib/firebase-admin/verify-id-token';
export const debugToken = async (token: string) => {
try {
const decoded = await verifyIdToken(token);
console.log('decoded', decoded);
} catch (error) {
console.error(error);
}
};
'use client';
import { getCurrentUser } from '@/lib/firebase/auth';
import { debugToken } from '../_actions/debug-token';
export const DebugToken = () => {
return (
<button
className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded'
onClick={async () => {
const idToken = await getCurrentUser()?.getIdToken();
if (!idToken) {
console.error('No idToken');
return;
}
await debugToken(idToken);
}}
>
Debug Token
</button>
);
};
ちゃんと decode できた!OK

Next Auth Credentials Provider で使おうとしたらエラー出た
[auth][cause]: JWTClaimValidationFailed: "iat" claim timestamp check failed (it should be in the past)
iat の値が未来の値になっている...?

エラー解決
clockTolerance を設定すればOK。
firebase 側の server と local dev server の server's clock のズレが原因だったぽい。clockTolerance である程度のズレを許容するよう変更。今回は 5 秒で設定。
const result = await jose.jwtVerify<TokenPayload>(token, publicKey, {
algorithms: [ALGORITHM],
audience: FIREBASE_PROJECT_ID,
issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`,
maxTokenAge: 60 * 60 * 60, // 1hour in seconds
requiredClaims: ['exp', 'sub', 'auth_time'],
+ clockTolerance: 5,
});

ちなみに、Auth.js(next-auth v5) では、clockTolerance
は15秒で設定されてた↓

ちゃんと Middleware (edge runtime) でも利用できることを確認!

library あった