ministry-platform-provider
Version:
TypeScript client library for Ministry Platform API integration
506 lines (427 loc) • 16.4 kB
Markdown
The Ministry Platform provider implements a dual authentication system that handles both API access (client credentials) and user authentication (OAuth2 authorization code flow). This documentation covers both authentication mechanisms and their integration with NextAuth.
## Authentication Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────┤
│ NextAuth Session Management + Ministry Platform Provider │
├─────────────────────────────────────────────────────────────┤
│ User Authentication │ API Authentication │
│ (Authorization Code Flow) │ (Client Credentials Flow) │
│ │ │
│ ministryPlatformAuthProvider │ clientCredentials.ts │
│ ↓ │ ↓ │
│ OAuth2 + OpenID Connect │ OAuth2 Client Creds │
└─────────────────────────────────────────────────────────────┘
↓
Ministry Platform OAuth Server
```
## API Authentication (Client Credentials)
### clientCredentials.ts
```typescript
export async function getClientCredentialsToken() {
const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!;
const mpOauthUrl = `${mpBaseUrl}/oauth`;
const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.MINISTRY_PLATFORM_CLIENT_ID!,
client_secret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!,
scope: "http://www.thinkministry.com/dataplatform/scopes/all",
});
const response = await fetch(`${mpOauthUrl}/connect/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
throw new Error(`Failed to get client credentials token: ${response.statusText}`);
}
return await response.json(); // returns { access_token, expires_in, token_type, ... }
}
```
**Purpose**: Provides server-to-server authentication for API operations
**OAuth2 Flow**: Client Credentials Grant
- **Grant Type**: `client_credentials`
- **Scope**: `http://www.thinkministry.com/dataplatform/scopes/all`
- **Authentication**: Client ID and Client Secret
**Usage**: Used by `MinistryPlatformClient` for all API operations
**Environment Variables Required**:
```env
MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com
MINISTRY_PLATFORM_CLIENT_ID=your_client_id
MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret
```
**Token Response**:
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "http://www.thinkministry.com/dataplatform/scopes/all"
}
```
**Error Handling**:
```typescript
try {
const tokenResponse = await getClientCredentialsToken();
console.log('Token acquired successfully');
} catch (error) {
console.error('Token acquisition failed:', error.message);
// Handle authentication errors
// - Check client credentials
// - Verify Ministry Platform connectivity
// - Check OAuth configuration
}
```
```typescript
export interface MinistryPlatformProfile {
display_name: string
family_name: string
given_name: string
middle_name?: string
nickname?: string
email: string
sub: string
name?: string
user_id?: string
}
export default function MinistryPlatform<P extends MinistryPlatformProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL
const mpOauthUrl = `${mpBaseUrl}/oauth`
return {
id: "ministryplatform",
name: "MinistryPlatform",
type: "oauth",
wellKnown: `${mpOauthUrl}/.well-known/openid-configuration`,
issuer: mpOauthUrl,
authorization: {
url: `${mpOauthUrl}/connect/authorize`,
params: {
scope: "openid offline_access http://www.thinkministry.com/dataplatform/scopes/all",
response_type: "code",
realm: "realm",
},
},
token: {
url: `${mpOauthUrl}/connect/token`,
params: {
grant_type: "authorization_code",
},
},
userinfo: {
url: `${mpOauthUrl}/connect/userinfo`,
},
checks: ["state"],
profile(profile) {
return {
id: profile.sub,
name: `${profile.given_name} ${profile.family_name}`,
firstName: profile.given_name,
lastName: profile.family_name,
email: profile.email,
image: null,
sub: profile.sub,
userId: profile.sub,
username: profile.name,
user_id: profile.user_id
}
},
options,
}
}
```
**Purpose**: Provides user authentication for login and session management
**OAuth2 Flow**: Authorization Code Grant with PKCE
- **Grant Type**: `authorization_code`
- **Scope**: `openid offline_access http://www.thinkministry.com/dataplatform/scopes/all`
- **Response Type**: `code`
- **Security**: State parameter for CSRF protection
**Key Features**:
- **OpenID Connect**: Full OIDC implementation with discovery
- **Offline Access**: Refresh token support
- **Profile Mapping**: Maps Ministry Platform user info to NextAuth format
- **Well-Known Configuration**: Automatic endpoint discovery
```typescript
import { NextAuthConfig } from "next-auth"
import MinistryPlatform from "@/providers/MinistryPlatform/ministryPlatformAuthProvider"
export default {
providers: [
MinistryPlatform({
clientId: process.env.MINISTRY_PLATFORM_CLIENT_ID!,
clientSecret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
// Persist Ministry Platform user info in JWT
if (account && profile) {
token.sub = profile.sub
token.user_id = profile.user_id
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
}
return token
},
async session({ session, token }) {
// Add Ministry Platform info to session
session.user.id = token.sub
session.user.userId = token.user_id
session.accessToken = token.accessToken
return session
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
} satisfies NextAuthConfig
```
```typescript
import { useSession } from "next-auth/react"
export default function UserProfile() {
const { data: session, status } = useSession()
if (status === "loading") return <p>Loading...</p>
if (status === "unauthenticated") return <p>Access Denied</p>
return (
<div>
<h1>Welcome {session.user.name}</h1>
<p>Email: {session.user.email}</p>
<p>User ID: {session.user.userId}</p>
</div>
)
}
```
```mermaid
sequenceDiagram
participant App as Application
participant Client as MinistryPlatformClient
participant MP as Ministry Platform OAuth
App->>Client: ensureValidToken()
Client->>Client: Check token expiration
alt Token expired
Client->>MP: POST /oauth/connect/token
Note over Client,MP: grant_type=client_credentials<br/>client_id=xxx<br/>client_secret=xxx
MP-->>Client: access_token
Client->>Client: Store token + expiration
end
Client-->>App: Token ready
App->>Client: Make API request
Client->>MP: API call with Bearer token
MP-->>Client: API response
Client-->>App: Response data
```
```mermaid
sequenceDiagram
participant User as User Browser
participant NextAuth as NextAuth
participant MP as Ministry Platform OAuth
User->>NextAuth: Sign in request
NextAuth->>MP: Redirect to /oauth/connect/authorize
Note over NextAuth,MP: response_type=code<br/>scope=openid offline_access<br/>state=csrf_token
MP->>User: Login form
User->>MP: Submit credentials
MP->>NextAuth: Redirect with authorization code
NextAuth->>MP: POST /oauth/connect/token
Note over NextAuth,MP: grant_type=authorization_code<br/>code=xxx<br/>client_credentials
MP-->>NextAuth: access_token + id_token + refresh_token
NextAuth->>MP: GET /oauth/connect/userinfo
MP-->>NextAuth: User profile data
NextAuth->>NextAuth: Create session
NextAuth-->>User: Redirect to application
```
```typescript
export class MinistryPlatformClient {
private token: string = "";
private expiresAt: Date = new Date(0);
public async ensureValidToken(): Promise<void> {
if (this.expiresAt < new Date()) {
const creds = await getClientCredentialsToken();
this.token = creds.access_token;
this.expiresAt = new Date(Date.now() + TOKEN_LIFE);
}
}
}
```
**Token Refresh Strategy**:
- **Proactive Refresh**: Tokens refreshed 5 minutes before expiration
- **Automatic Retry**: Failed requests trigger token refresh
- **Shared Instance**: Single token serves all API operations
- **Thread Safety**: Token refresh is atomic
| Aspect | Session Token (User Auth) | API Token (Client Creds) |
|--------|---------------------------|---------------------------|
| **Purpose** | User authentication & authorization | API access for operations |
| **Lifetime** | Session duration (hours/days) | Short-lived (1 hour) |
| **Refresh** | NextAuth handles refresh tokens | Auto-refresh before expiry |
| **Scope** | User permissions & profile | Full API access |
| **Storage** | Encrypted session cookie | Memory (not persisted) |
## Security Considerations
### Environment Variables
```env
# Required for both authentication flows
MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com
MINISTRY_PLATFORM_CLIENT_ID=your_application_client_id
MINISTRY_PLATFORM_CLIENT_SECRET=your_application_client_secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret_key
AUTH_TRUST_HOST=true
```
1. **Client Secret Protection**:
```typescript
// ❌ Never expose client secrets in browser code
const secret = process.env.MINISTRY_PLATFORM_CLIENT_SECRET; // Server-side only
// ✅ Use environment variables server-side
const secret = process.env.MINISTRY_PLATFORM_CLIENT_SECRET!;
```
2. **Token Storage**:
```typescript
// ✅ Tokens stored in memory, not persisted
private token: string = "";
// ❌ Avoid storing in localStorage or sessionStorage
localStorage.setItem('token', token); // Security risk
```
3. **HTTPS Enforcement**:
```typescript
// Ensure all OAuth flows use HTTPS in production
const mpOauthUrl = `${mpBaseUrl}/oauth`;
// mpBaseUrl should always be https:// in production
```
```typescript
// Handle client credentials errors
const handleApiAuth = async () => {
try {
await client.ensureValidToken();
} catch (error) {
if (error.message.includes('401')) {
// Invalid client credentials
console.error('Check client ID and secret configuration');
throw new Error('API authentication failed');
}
if (error.message.includes('403')) {
// Insufficient permissions
console.error('Client lacks required permissions');
throw new Error('API access denied');
}
throw error;
}
};
// Handle user authentication errors
const handleUserAuth = async () => {
try {
const session = await getServerSession();
if (!session) {
throw new Error('User not authenticated');
}
return session;
} catch (error) {
console.error('User authentication error:', error);
// Redirect to sign-in page
redirect('/auth/signin');
}
};
```
```typescript
const retryWithAuth = async <T>(operation: () => Promise<T>, maxRetries = 3): Promise<T> => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.ensureValidToken();
return await operation();
} catch (error) {
if (error.message.includes('401') && attempt < maxRetries) {
// Force token refresh and retry
client.forceTokenRefresh();
continue;
}
throw error;
}
}
throw new Error('Max authentication retries exceeded');
};
```
```typescript
// Enable detailed OAuth logging
console.log('MinistryPlatform provider initialized with base URL:', mpBaseUrl);
console.log('OAuth URL:', mpOauthUrl);
console.log('Client ID:', process.env.MINISTRY_PLATFORM_CLIENT_ID);
// Never log client secret in production
// Token lifecycle logging
console.log("Checking token validity...");
console.log("Expires at: ", this.expiresAt);
console.log("Current time: ", new Date());
```
1. **Invalid Client Credentials**:
```
Error: Failed to get client credentials token: 401 Unauthorized
```
- Verify `MINISTRY_PLATFORM_CLIENT_ID` and `MINISTRY_PLATFORM_CLIENT_SECRET`
- Check OAuth application configuration in Ministry Platform
- Ensure client has appropriate scopes
2. **CORS Issues**:
```
Error: Access to fetch blocked by CORS policy
```
- Ensure OAuth flows happen server-side
- Check Ministry Platform CORS configuration
- Verify callback URLs in OAuth application
3. **Session Issues**:
```
Error: Invalid session or expired token
```
- Check `NEXTAUTH_SECRET` configuration
- Verify `NEXTAUTH_URL` matches deployment URL
- Clear browser cookies and retry
```typescript
// Test client credentials flow
const testApiAuth = async () => {
try {
const token = await getClientCredentialsToken();
console.log('✅ API authentication successful');
console.log('Token type:', token.token_type);
console.log('Expires in:', token.expires_in, 'seconds');
} catch (error) {
console.error('❌ API authentication failed:', error.message);
}
};
// Test user authentication flow
const testUserAuth = async () => {
try {
const session = await getServerSession();
if (session) {
console.log('✅ User authentication successful');
console.log('User:', session.user.name);
console.log('Email:', session.user.email);
} else {
console.log('❌ No active user session');
}
} catch (error) {
console.error('❌ User authentication failed:', error.message);
}
};
```
This dual authentication system provides secure, scalable access to Ministry Platform while maintaining excellent developer experience and robust error handling.